diff --git a/.env.example b/.env.example index 067aad9cc6e..79b2adaf0c8 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,13 @@ # T3CODE_CLERK_JWT_TEMPLATE=t3-relay # T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... +# Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API +# hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk +# returns a different RP ID or when multiple domains must be entitled. +# T3CODE_APPLE_TEAM_ID=ABC1234567 +# T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile +# T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com + # Get this from your relay deployment. `infra/relay` deploys update it automatically. # T3CODE_RELAY_URL=https://relay.example.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a83f60a470a..f70548e7280 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: run: | test -f apps/desktop/dist-electron/preload.cjs grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs + grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs test: name: Test @@ -60,53 +61,6 @@ jobs: - name: Test run: vp run test - test_browser: - name: Test Browser - if: false - runs-on: t3code-arc - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install browser test runtime - run: vp run --filter @t3tools/web test:browser:install - - - name: Browser test / Chat view - working-directory: apps/web - run: vp test run --mode browser --browser=chromium src/components/ChatView.browser.tsx - - - name: Browser test / Chat markdown - working-directory: apps/web - run: vp test run --mode browser --browser=chromium src/components/ChatMarkdown.browser.tsx - - - name: Browser test / Components - working-directory: apps/web - run: | - vp test run --mode browser --browser=chromium \ - src/components/GitActionsControl.browser.tsx \ - src/components/KeybindingsToast.browser.tsx \ - src/components/ThreadTerminalDrawer.browser.tsx \ - src/components/chat/MessagesTimeline.browser.tsx \ - src/components/chat/ProviderModelPicker.browser.tsx \ - src/components/chat/CompactComposerControlsMenu.browser.tsx \ - src/components/settings/SettingsPanels.browser.tsx - mobile_native_static_analysis: name: Mobile Native Static Analysis runs-on: macos-15 diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 3d5661441a7..5ade9f5f274 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -2,12 +2,13 @@ name: Mobile EAS Preview on: pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] jobs: preview: name: EAS Preview - if: github.repository_owner == 'pingdotgg' - runs-on: t3code-arc + if: github.repository_owner == 'pingdotgg' && contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') + runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read pull-requests: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1f9b632387..b42839aa1b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -517,6 +517,9 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} @@ -545,9 +548,21 @@ jobs: if [[ "${{ matrix.platform }}" == "mac" ]]; then if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD"; then if has_all "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then + if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then + echo "macOS notarization is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 + exit 1 + fi + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" printf '%s' "$APPLE_API_KEY" > "$key_path" export APPLE_API_KEY="$key_path" + + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + echo "macOS notarization enabled." else unset APPLE_API_KEY APPLE_API_KEY_ID APPLE_API_ISSUER diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md new file mode 100644 index 00000000000..afbdc55ba60 --- /dev/null +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -0,0 +1,83 @@ +--- +title: Effect Service Conventions +model: claude-opus-4-8 +effort: high +input: full_diff +tools: + - browse_code + - git_tools + - github_api_read_only + - modify_pr +include: + - "apps/**/*.ts" + - "apps/**/*.tsx" + - "packages/**/*.ts" + - "packages/**/*.tsx" + - "infra/**/*.ts" + - "infra/**/*.tsx" +conclusion: failure +showToolCalls: true +--- + +# Effect service review + +Review changed TypeScript and directly affected call sites for the conventions below. Apply them when a pull request creates, moves, refactors, or consumes an Effect service. Do not demand unrelated repository-wide cleanup. Treat these instructions as authoritative when older code differs. + +## Imports and module namespaces + +- Import Effect library modules from their subpaths as namespaces, for example `import * as Effect from "effect/Effect"` and `import * as Layer from "effect/Layer"`. Flag consolidated named imports from `"effect"` in touched Effect service code. +- At a service boundary, import the local service module as a namespace and use its public module shape: `WorkspacePaths.WorkspacePaths`, `WorkspacePaths.make`, and `WorkspacePaths.layer`. Flag aliases such as `import { layer as workspacePathsLayer }` that erase the module namespace. +- Namespace imports are not a blanket rule. Keep named imports for whole packages such as `@t3tools/contracts`, and for modules used only for a pure helper, error, schema, config value, or standalone type. Do not request `import type * as Contracts`. +- A package subpath that is itself a service module may use a namespace import when callers access its service/tag, `make`, or `layer` members. +- When a barrel exposes an entire service module, prefer `export * as TokenStore from "./tokenStore.ts"` so consumers can use `TokenStore.TokenStore` and `TokenStore.layer`. Do not individually rename `make` and `layer` exports to simulate a namespace. + +## Service definition + +- Use the canonical single-file order: imports, error/schema declarations, the `Context.Service` tag with its inline interface, `make`, then `layer`. +- Keep a service's schemas/errors, `Context.Service` tag, construction, and layer in one canonical module when they form one implementation. +- Define the service interface inline in the `Context.Service` declaration. Do not retain a standalone `FooShape` or `FooServiceShape` interface/type. +- Refer to the inferred service interface as `Foo["Service"]`, including in mechanically updated orchestration, MCP, tests, and integration harnesses. +- Export a real `make` when the module owns construction. Do not create `make = Effect.succeed(...)` solely to force `Layer.effect`. +- Export the canonical layer as `export const layer = Layer...`. `Layer.effect` is not required: use `Layer.succeed`, `Layer.scoped`, or another appropriate constructor when that matches the implementation. +- In a concrete implementation module already named for the implementation, use plain `make` and `layer` (for example `BunPtyAdapter.ts` and `NodePtyAdapter.ts`). +- Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`. +- `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. + +## Errors and predicates + +- Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. +- `Schema.Defect()` is not a substitute for modeling a generic error: its tag, fields, or both must identify the failure structurally, and its `message` must not merely stringify an opaque cause. A semantically precise error tag may preserve a real `cause` without inventing a redundant singleton field when no additional variable context exists; still retain any real path, resource, request, or entity context available at the wrapping site. +- Capture stable, serializable domain context such as the operation or stage, resource/path or entity identifier, and normalized category/status. Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error. Do not add a `detail` field that merely copies `cause.message` and then use it to construct the wrapper message. +- Keep direct error attributes and log annotations safe and bounded. Do not copy raw wire payloads, command arguments or output, signed URLs, credentials, query strings, fragments, selectors, or arbitrary defect text into `detail`, `reason`, `message`, or a parallel log payload. Preserve the exact underlying value only as `cause`; expose normalized categories plus lengths/counts and safe URL protocol/hostname diagnostics where useful. Logging a sanitized error must not reintroduce a removed legacy `detail` or serialized `cause` field beside it. +- When translating or wrapping a real failure, preserve the immediate underlying error itself as `cause` alongside the structural fields so the complete error chain and stack remain available. If every construction wraps a failure, `cause` should be required; make it optional only when the same error can legitimately originate without an underlying failure. +- At a translation boundary, pass through an already structured domain error when it is part of the declared target error channel. Wrap only unknown or genuinely lower-level failures. A static factory or mapper may perform this classification when it is reused and keeps the policy next to the target error type. +- Derive the wrapper's `message` exclusively from its stable structural attributes, never from `cause`, `cause.message`, or a stringified defect. Do not replace the immediate error with only `error.cause`, erase a structured upstream error into a string, or manufacture an `Error` merely to populate `cause`. Pure validation/domain errors created without an underlying failure do not need a cause. +- Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Choose one coherent model: use distinct error classes and omit the redundant discriminator when callers or messages treat the failures as genuinely different, or use one service-level error with a multi-value operation discriminator and a generic message derived from that operation when the failures share the same semantics. +- Treat an error message exposed through an HTTP/RPC response, persisted state, UI, or another caller-visible boundary as behavior. Preserve those messages during a structural refactor. Existing distinct caller-visible messages are evidence that the failures should normally remain distinct error tags without redundant singleton discriminators, rather than being collapsed into a generic operation error. +- Split semantically distinct failures into separate error classes when a `reason`, `kind`, `phase`, or similar discriminator is used to choose the user-facing message or drive caller control flow. A discriminator used only for internal diagnostics may remain a field. +- Use `Schema.Union` of error classes when a shared schema, predicate, or helper type is useful. +- Export direct schema predicates such as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a redundant function with the same signature. +- Do not introduce a large `switch` or lookup table in an error's `message` getter to model failures that deserve separate error classes. +- Catch statically known tagged failures with `Effect.catchTags({ ... })`, including when handling only one tag. Do not use `catchIf` with a schema predicate merely to recover one or more known `_tag` variants, and do not use `catchTag`. `Effect.catch` is appropriate when the entire error channel is intentionally handled; `catchIf` remains appropriate for genuinely structural predicates such as inspecting an underlying platform error code. +- Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`, including curried aliases used once with `mapError`. Construct the error at the failure boundary so its attributes and cause remain visible. Keep a mapper only when it performs real normalization, passes through existing domain errors, or adds reusable context/control flow. +- When a reusable error-to-error translation clearly belongs to the target error type, prefer a descriptive static factory on that error class over a detached production-side switch. Do not force a static method for one-off inline mappings. + +## File layout and migrations + +- When combining `domain/Services/Foo.ts` and `domain/Layers/Foo.ts`, hoist the result to `domain/Foo.ts`. +- Delete the old service/layer files. Do not leave compatibility re-export shims. Mechanically update every consumer, including orchestration, MCP, tests, and integration harnesses, to the canonical path. +- Do not flag genuinely separate implementation/adapter modules merely because they remain in an implementation-oriented directory. +- Avoid substantive orchestration or MCP redesign in service-cleanup PRs. Mechanical import, layer, and `Service["Service"]` updates are expected when required to remove obsolete paths or shapes. + +## Change discipline + +- Preserve useful comments, invariants, and specification documentation while moving code. +- Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. +- If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. +- Do not require `Layer.effect`, universal namespace imports, generic `make`/`layer` names for abstract-port implementations, separate error classes for diagnostic-only fields, or new tests for import-only changes. + +## Reporting + +Report only concrete violations introduced or retained in the pull request's changed scope. Prefer precise inline comments on the smallest relevant line range and state the expected fix. A clear convention violation may fail the check. Do not fail for optional style preferences or unrelated legacy code. + +This check defaults to failure. When there are no findings, stop immediately and make the entire final response exactly `All clear` on one line. Do not add a title, explanation, punctuation, Markdown, JSON, or trailing analysis, and do not continue reasoning after deciding the review is clean. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 065c1a70176..9444f734ab8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,8 @@ "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { + "@clerk/electron": "catalog:", + "@clerk/electron-passkeys": "catalog:", "@effect/platform-node": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", @@ -20,6 +22,7 @@ "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "41.5.0", + "electron-store": "^8.2.0", "electron-updater": "^6.6.2", "playwright-core": "1.60.0", "react-grab": "^0.1.32" diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs index c45f81268a6..a5dbdcfbe69 100644 --- a/apps/desktop/scripts/build-preview-annotation-css.mjs +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -1,23 +1,23 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { compile } from "tailwindcss"; -const directory = dirname(fileURLToPath(import.meta.url)); -const appRoot = join(directory, ".."); -const sourcePath = join(appRoot, "src", "preview", "Annotation.css"); -const preloadPath = join(appRoot, "src", "preview", "PickPreload.ts"); -const outputPath = join(appRoot, "src", "preview", "AnnotationStyles.generated.ts"); -const require = createRequire(import.meta.url); -const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); +const directory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const appRoot = NodePath.join(directory, ".."); +const sourcePath = NodePath.join(appRoot, "src", "preview", "Annotation.css"); +const preloadPath = NodePath.join(appRoot, "src", "preview", "PickPreload.ts"); +const outputPath = NodePath.join(appRoot, "src", "preview", "AnnotationStyles.generated.ts"); +const require = NodeModule.createRequire(import.meta.url); +const tailwindRoot = NodePath.dirname(require.resolve("tailwindcss/package.json")); const [annotationSource, preloadSource, themeSource, preflightSource] = await Promise.all([ - readFile(sourcePath, "utf8"), - readFile(preloadPath, "utf8"), - readFile(join(tailwindRoot, "theme.css"), "utf8"), - readFile(join(tailwindRoot, "preflight.css"), "utf8"), + NodeFSP.readFile(sourcePath, "utf8"), + NodeFSP.readFile(preloadPath, "utf8"), + NodeFSP.readFile(NodePath.join(tailwindRoot, "theme.css"), "utf8"), + NodeFSP.readFile(NodePath.join(tailwindRoot, "preflight.css"), "utf8"), ]); const candidates = new Set( @@ -37,4 +37,4 @@ const encodedCss = `'${css .replaceAll("\n", "\\n")}'`; const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`; -await writeFile(outputPath, moduleSource); +await NodeFSP.writeFile(outputPath, moduleSource); diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 58ccfe90eb9..c28d5ec358b 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,7 +1,7 @@ -import { spawn, spawnSync } from "node:child_process"; -import { watch } from "node:fs"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; -import { join } from "node:path"; +import * as NodePath from "node:path"; import { desktopDir, @@ -64,7 +64,7 @@ function killChildTreeByPid(pid, signal) { return; } - spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); + NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); } function cleanupStaleDevApps() { @@ -72,7 +72,9 @@ function cleanupStaleDevApps() { return; } - spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { stdio: "ignore" }); + NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { + stdio: "ignore", + }); } function startApp() { @@ -87,7 +89,7 @@ function startApp() { ? electronArgs : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; const electronCommand = resolveElectronLaunchCommand(launchArgs); - const app = spawn(electronCommand.electronPath, electronCommand.args, { + const app = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: childEnv, stdio: "inherit", @@ -180,8 +182,8 @@ function scheduleRestart() { function startWatchers() { for (const { directory, files } of watchedDirectories) { - const watcher = watch( - join(desktopDir, directory), + const watcher = NodeFS.watch( + NodePath.join(desktopDir, directory), { persistent: true }, (_eventType, filename) => { if (typeof filename !== "string" || !files.has(filename)) { @@ -202,7 +204,9 @@ function killChildTree(signal) { } // Kill direct children as a final fallback in case normal shutdown leaves stragglers. - spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); + NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { + stdio: "ignore", + }); } async function shutdown(exitCode) { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 52b6dd5cc6e..69df02fb80d 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -1,29 +1,18 @@ // This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" -import { spawnSync } from "node:child_process"; -import { - copyFileSync, - chmodSync, - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { createRequire } from "node:module"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const __dirname = dirname(fileURLToPath(import.meta.url)); -export const desktopDir = resolve(__dirname, ".."); -const repoRoot = resolve(desktopDir, "..", ".."); -const devBundleIdSuffix = basename(repoRoot) +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +export const desktopDir = NodePath.resolve(__dirname, ".."); +const repoRoot = NodePath.resolve(desktopDir, "..", ".."); +const devBundleIdSuffix = NodePath.basename(repoRoot) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, ""); export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; @@ -31,31 +20,36 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 11; -const defaultIconPath = join(desktopDir, "resources", "icon.icns"); -const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); +const LAUNCHER_VERSION = 12; +const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); +const developmentMacIconPngPath = NodePath.join( + repoRoot, + "assets", + "dev", + "blueprint-macos-1024.png", +); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. const hostPlatform = NodeOS.platform(); -function resolveDevelopmentProtocolCallbackPort() { - const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); - if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort < 65535) { - return configuredPort + 1; - } - return 13774; -} - function setPlistString(plistPath, key, value) { - const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { - encoding: "utf8", - }); + const replaceResult = NodeChildProcess.spawnSync( + "plutil", + ["-replace", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); if (replaceResult.status === 0) { return; } - const insertResult = spawnSync("plutil", ["-insert", key, "-string", value, plistPath], { - encoding: "utf8", - }); + const insertResult = NodeChildProcess.spawnSync( + "plutil", + ["-insert", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); if (insertResult.status === 0) { return; } @@ -66,16 +60,24 @@ function setPlistString(plistPath, key, value) { function setPlistJson(plistPath, key, value) { const serialized = JSON.stringify(value); - const replaceResult = spawnSync("plutil", ["-replace", key, "-json", serialized, plistPath], { - encoding: "utf8", - }); + const replaceResult = NodeChildProcess.spawnSync( + "plutil", + ["-replace", key, "-json", serialized, plistPath], + { + encoding: "utf8", + }, + ); if (replaceResult.status === 0) { return; } - const insertResult = spawnSync("plutil", ["-insert", key, "-json", serialized, plistPath], { - encoding: "utf8", - }); + const insertResult = NodeChildProcess.spawnSync( + "plutil", + ["-insert", key, "-json", serialized, plistPath], + { + encoding: "utf8", + }, + ); if (insertResult.status === 0) { return; } @@ -85,7 +87,7 @@ function setPlistJson(plistPath, key, value) { } function runChecked(command, args) { - const result = spawnSync(command, args, { encoding: "utf8" }); + const result = NodeChildProcess.spawnSync(command, args, { encoding: "utf8" }); if (result.status === 0) { return; } @@ -99,8 +101,7 @@ function shellSingleQuote(value) { } function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { - const mainEntryPath = join(desktopDir, "dist-electron", "main.cjs"); - const protocolCallbackUrl = `http://127.0.0.1:${resolveDevelopmentProtocolCallbackPort()}/auth/callback`; + const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); const envEntries = [ ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], ["T3CODE_PORT", process.env.T3CODE_PORT], @@ -109,28 +110,17 @@ function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL], ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], - ["T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED", "1"], - ["T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL", protocolCallbackUrl], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); - writeFileSync( + NodeFS.writeFileSync( targetBinaryPath, [ "#!/bin/sh", ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`), - 'for arg in "$@"; do', - ' case "$arg" in', - " t3code-dev://auth/callback*)", - ' if [ -n "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" ]; then', - ' /usr/bin/curl -fsS --max-time 2 -X POST --data-binary "$arg" "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" >/dev/null 2>&1 && exit 0', - " fi", - " ;;", - " esac", - "done", `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`, "", ].join("\n"), ); - chmodSync(targetBinaryPath, 0o755); + NodeFS.chmodSync(targetBinaryPath, 0o755); } function registerMacLauncherBundle(appBundlePath) { @@ -160,21 +150,24 @@ function registerMacLauncherBundle(appBundlePath) { } function ensureDevelopmentIconIcns(runtimeDir) { - const generatedIconPath = join(runtimeDir, "icon-dev.icns"); - mkdirSync(runtimeDir, { recursive: true }); + const generatedIconPath = NodePath.join(runtimeDir, "icon-dev.icns"); + NodeFS.mkdirSync(runtimeDir, { recursive: true }); - if (!existsSync(developmentMacIconPngPath)) { + if (!NodeFS.existsSync(developmentMacIconPngPath)) { return defaultIconPath; } - const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs; - if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) { + const sourceMtimeMs = NodeFS.statSync(developmentMacIconPngPath).mtimeMs; + if ( + NodeFS.existsSync(generatedIconPath) && + NodeFS.statSync(generatedIconPath).mtimeMs >= sourceMtimeMs + ) { return generatedIconPath; } - const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-")); - const iconsetDir = join(iconsetRoot, "icon.iconset"); - mkdirSync(iconsetDir, { recursive: true }); + const iconsetRoot = NodeFS.mkdtempSync(NodePath.join(runtimeDir, "dev-iconset-")); + const iconsetDir = NodePath.join(iconsetRoot, "icon.iconset"); + NodeFS.mkdirSync(iconsetDir, { recursive: true }); try { for (const size of [16, 32, 128, 256, 512]) { @@ -184,7 +177,7 @@ function ensureDevelopmentIconIcns(runtimeDir) { String(size), developmentMacIconPngPath, "--out", - join(iconsetDir, `icon_${size}x${size}.png`), + NodePath.join(iconsetDir, `icon_${size}x${size}.png`), ]); const retinaSize = size * 2; @@ -194,7 +187,7 @@ function ensureDevelopmentIconIcns(runtimeDir) { String(retinaSize), developmentMacIconPngPath, "--out", - join(iconsetDir, `icon_${size}x${size}@2x.png`), + NodePath.join(iconsetDir, `icon_${size}x${size}@2x.png`), ]); } @@ -207,12 +200,12 @@ function ensureDevelopmentIconIcns(runtimeDir) { ); return defaultIconPath; } finally { - rmSync(iconsetRoot, { recursive: true, force: true }); + NodeFS.rmSync(iconsetRoot, { recursive: true, force: true }); } } function patchMainBundleInfoPlist(appBundlePath, iconPath) { - const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); + const infoPlistPath = NodePath.join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); @@ -224,9 +217,9 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { }, ]); - const resourcesDir = join(appBundlePath, "Contents", "Resources"); - copyFileSync(iconPath, join(resourcesDir, "icon.icns")); - copyFileSync(iconPath, join(resourcesDir, "electron.icns")); + const resourcesDir = NodePath.join(appBundlePath, "Contents", "Resources"); + NodeFS.copyFileSync(iconPath, NodePath.join(resourcesDir, "icon.icns")); + NodeFS.copyFileSync(iconPath, NodePath.join(resourcesDir, "electron.icns")); } function patchHelperBundleInfoPlists(appBundlePath) { @@ -238,7 +231,7 @@ function patchHelperBundleInfoPlists(appBundlePath) { ]; for (const [bundleName, bundleIdentifierSuffix, bundleDisplayName] of helperBundleNames) { - const infoPlistPath = join( + const infoPlistPath = NodePath.join( appBundlePath, "Contents", "Frameworks", @@ -246,7 +239,7 @@ function patchHelperBundleInfoPlists(appBundlePath) { "Contents", "Info.plist", ); - if (!existsSync(infoPlistPath)) { + if (!NodeFS.existsSync(infoPlistPath)) { continue; } @@ -262,34 +255,34 @@ function patchHelperBundleInfoPlists(appBundlePath) { function readJson(path) { try { - return JSON.parse(readFileSync(path, "utf8")); + return JSON.parse(NodeFS.readFileSync(path, "utf8")); } catch { return null; } } function buildMacLauncher(electronBinaryPath) { - const sourceAppBundlePath = resolve(dirname(electronBinaryPath), "../.."); - const runtimeDir = join(desktopDir, ".electron-runtime"); - const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); + const sourceAppBundlePath = NodePath.resolve(NodePath.dirname(electronBinaryPath), "../.."); + const runtimeDir = NodePath.join(desktopDir, ".electron-runtime"); + const targetAppBundlePath = NodePath.join(runtimeDir, `${APP_DISPLAY_NAME}.app`); + const targetBinaryPath = NodePath.join(targetAppBundlePath, "Contents", "MacOS", "Electron"); const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; - const metadataPath = join(runtimeDir, "metadata.json"); + const metadataPath = NodePath.join(runtimeDir, "metadata.json"); - mkdirSync(runtimeDir, { recursive: true }); + NodeFS.mkdirSync(runtimeDir, { recursive: true }); const expectedMetadata = { launcherVersion: LAUNCHER_VERSION, sourceAppBundlePath, - sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, - iconMtimeMs: statSync(iconPath).mtimeMs, + sourceAppMtimeMs: NodeFS.statSync(sourceAppBundlePath).mtimeMs, + iconMtimeMs: NodeFS.statSync(iconPath).mtimeMs, appBundleId: APP_BUNDLE_ID, appProtocolSchemes: APP_PROTOCOL_SCHEMES, }; const currentMetadata = readJson(metadataPath); if ( - existsSync(targetBinaryPath) && + NodeFS.existsSync(targetBinaryPath) && currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { @@ -297,18 +290,21 @@ function buildMacLauncher(electronBinaryPath) { return targetBinaryPath; } - rmSync(targetAppBundlePath, { recursive: true, force: true }); + NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); // verbatimSymlinks keeps the framework's relative symlinks intact // (e.g. Resources -> Versions/Current/Resources). Without it cpSync // rewrites them to absolute paths into node_modules, which escape the // bundle and crash sandboxed helper processes (icudtl.dat not found). - cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true, verbatimSymlinks: true }); + NodeFS.cpSync(sourceAppBundlePath, targetAppBundlePath, { + recursive: true, + verbatimSymlinks: true, + }); patchMainBundleInfoPlist(targetAppBundlePath, iconPath); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); } - writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + NodeFS.writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); registerMacLauncherBundle(targetAppBundlePath); return targetBinaryPath; @@ -319,9 +315,9 @@ function isLinuxSetuidSandboxConfigured(electronBinaryPath) { return true; } - const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + const sandboxPath = NodePath.join(NodePath.dirname(electronBinaryPath), "chrome-sandbox"); try { - const sandboxStat = statSync(sandboxPath); + const sandboxStat = NodeFS.statSync(sandboxPath); return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; } catch { return false; @@ -342,7 +338,7 @@ function resolveLinuxSandboxArgs(electronBinaryPath) { export function resolveElectronPath() { ensureElectronRuntime(); - const require = createRequire(import.meta.url); + const require = NodeModule.createRequire(import.meta.url); const electronBinaryPath = require("electron"); if (hostPlatform !== "darwin") { @@ -365,11 +361,11 @@ export function resolveDevProtocolClient() { return null; } - const require = createRequire(import.meta.url); + const require = NodeModule.createRequire(import.meta.url); const electronBinaryPath = require("electron"); const launcherBinaryPath = buildMacLauncher(electronBinaryPath); return { - appBundlePath: resolve(launcherBinaryPath, "..", "..", ".."), + appBundlePath: NodePath.resolve(launcherBinaryPath, "..", "..", ".."), appBundleId: APP_BUNDLE_ID, }; } diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index 0a13506d341..c37838ab183 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -1,14 +1,14 @@ -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { arch, platform, tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { spawnSync } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; -const require = createRequire(import.meta.url); +const require = NodeModule.createRequire(import.meta.url); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. -const hostPlatform = platform(); +const hostPlatform = NodeOS.platform(); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. -const hostArch = arch(); +const hostArch = NodeOS.arch(); function getPlatformPath() { switch (hostPlatform) { @@ -27,26 +27,28 @@ function getPlatformPath() { function ensureExecutable(filePath) { if (hostPlatform !== "win32") { - chmodSync(filePath, 0o755); + NodeFS.chmodSync(filePath, 0o755); } } function repairPathFile(electronDir, platformPath) { - const pathFile = join(electronDir, "path.txt"); - const currentPath = existsSync(pathFile) ? readFileSync(pathFile, "utf8") : undefined; + const pathFile = NodePath.join(electronDir, "path.txt"); + const currentPath = NodeFS.existsSync(pathFile) + ? NodeFS.readFileSync(pathFile, "utf8") + : undefined; if (currentPath !== platformPath) { - writeFileSync(pathFile, platformPath); + NodeFS.writeFileSync(pathFile, platformPath); } } function getRequiredRuntimePaths(electronDir, platformPath) { - const paths = [join(electronDir, "dist", platformPath)]; + const paths = [NodePath.join(electronDir, "dist", platformPath)]; if (hostPlatform === "darwin") { paths.push( - join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), - join( + NodePath.join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), + NodePath.join( electronDir, "dist", "Electron.app", @@ -66,7 +68,7 @@ function isMachO(filePath) { return true; } - const result = spawnSync("file", ["-b", filePath], { + const result = NodeChildProcess.spawnSync("file", ["-b", filePath], { encoding: "utf8", }); @@ -75,7 +77,7 @@ function isMachO(filePath) { function missingRuntimePaths(electronDir, platformPath) { return getRequiredRuntimePaths(electronDir, platformPath).filter((runtimePath) => { - return !existsSync(runtimePath); + return !NodeFS.existsSync(runtimePath); }); } @@ -85,8 +87,8 @@ function invalidRuntimePaths(electronDir, platformPath) { } return [ - join(electronDir, "dist", platformPath), - join( + NodePath.join(electronDir, "dist", platformPath), + NodePath.join( electronDir, "dist", "Electron.app", @@ -95,11 +97,11 @@ function invalidRuntimePaths(electronDir, platformPath) { "Electron Framework.framework", "Electron Framework", ), - ].filter((runtimePath) => existsSync(runtimePath) && !isMachO(runtimePath)); + ].filter((runtimePath) => NodeFS.existsSync(runtimePath) && !isMachO(runtimePath)); } function runChecked(command, args) { - const result = spawnSync(command, args, { + const result = NodeChildProcess.spawnSync(command, args, { encoding: "utf8", stdio: "inherit", }); @@ -114,8 +116,8 @@ function runChecked(command, args) { } function installElectronRuntime(electronDir, version) { - const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-")); - const zipPath = join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-electron-")); + const zipPath = NodePath.join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); try { runChecked("curl", [ @@ -125,34 +127,34 @@ function installElectronRuntime(electronDir, version) { zipPath, ]); if (hostPlatform === "darwin") { - runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); + runChecked("ditto", ["-x", "-k", zipPath, NodePath.join(electronDir, "dist")]); } else { runChecked("python3", [ "-c", "import os, sys, zipfile; os.makedirs(sys.argv[2], exist_ok=True); zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])", zipPath, - join(electronDir, "dist"), + NodePath.join(electronDir, "dist"), ]); } } finally { - rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } } export function ensureElectronRuntime() { const electronPackageJsonPath = require.resolve("electron/package.json"); - const electronPackageJson = JSON.parse(readFileSync(electronPackageJsonPath, "utf8")); - const electronDir = dirname(electronPackageJsonPath); + const electronPackageJson = JSON.parse(NodeFS.readFileSync(electronPackageJsonPath, "utf8")); + const electronDir = NodePath.dirname(electronPackageJsonPath); const platformPath = getPlatformPath(); - const electronPath = join(electronDir, "dist", platformPath); + const electronPath = NodePath.join(electronDir, "dist", platformPath); const missingBeforeInstall = missingRuntimePaths(electronDir, platformPath); const invalidBeforeInstall = invalidRuntimePaths(electronDir, platformPath); if (missingBeforeInstall.length > 0 || invalidBeforeInstall.length > 0) { - if (existsSync(join(electronDir, "dist"))) { - rmSync(join(electronDir, "dist"), { recursive: true, force: true }); + if (NodeFS.existsSync(NodePath.join(electronDir, "dist"))) { + NodeFS.rmSync(NodePath.join(electronDir, "dist"), { recursive: true, force: true }); } - rmSync(join(electronDir, "path.txt"), { force: true }); + NodeFS.rmSync(NodePath.join(electronDir, "path.txt"), { force: true }); installElectronRuntime(electronDir, electronPackageJson.version); } diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 48a2e168a2b..fea5f0a120e 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -1,16 +1,16 @@ -import { spawn } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const desktopDir = resolve(__dirname, ".."); -const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const desktopDir = NodePath.resolve(__dirname, ".."); +const mainJs = NodePath.resolve(desktopDir, "dist-electron/main.cjs"); console.log("\nLaunching Electron smoke test..."); const electronCommand = resolveElectronLaunchCommand([mainJs]); -const child = spawn(electronCommand.electronPath, electronCommand.args, { +const child = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index d959b4ab1f0..ecabd81fb40 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import * as NodeChildProcess from "node:child_process"; import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; @@ -6,7 +6,7 @@ const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.cjs"]); -const child = spawn(electronCommand.electronPath, electronCommand.args, { +const child = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/scripts/wait-for-resources.mjs b/apps/desktop/scripts/wait-for-resources.mjs index 2b0a60c5d98..00455f4db72 100644 --- a/apps/desktop/scripts/wait-for-resources.mjs +++ b/apps/desktop/scripts/wait-for-resources.mjs @@ -1,13 +1,13 @@ -import * as FileSystem from "node:fs/promises"; -import * as Net from "node:net"; -import * as Path from "node:path"; -import * as Timers from "node:timers/promises"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodePath from "node:path"; +import * as NodeTimersPromises from "node:timers/promises"; const defaultTcpHosts = ["127.0.0.1", "localhost", "::1"]; async function fileExists(filePath) { try { - await FileSystem.access(filePath); + await NodeFSP.access(filePath); return true; } catch { return false; @@ -16,7 +16,7 @@ async function fileExists(filePath) { function tcpPortIsReady({ host, port, connectTimeoutMs = 500 }) { return new Promise((resolveReady) => { - const socket = Net.createConnection({ host, port }); + const socket = NodeNet.createConnection({ host, port }); let settled = false; const finish = (ready) => { @@ -47,7 +47,7 @@ async function resolvePendingResources({ baseDir, files, tcpPort, tcpHosts, conn const pendingFiles = []; for (const relativeFilePath of files) { - const ready = await fileExists(Path.resolve(baseDir, relativeFilePath)); + const ready = await fileExists(NodePath.resolve(baseDir, relativeFilePath)); if (!ready) { pendingFiles.push(relativeFilePath); } @@ -114,6 +114,6 @@ export async function waitForResources({ ); } - await Timers.setTimeout(intervalMs); + await NodeTimersPromises.setTimeout(intervalMs); } } diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4da1ce63bdf..214fd383e04 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,8 +1,8 @@ import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as NetService from "@t3tools/shared/Net"; import * as Crypto from "effect/Crypto"; @@ -11,12 +11,13 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; -import * as DesktopCloudAuth from "./DesktopCloudAuth.ts"; +import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; @@ -32,22 +33,24 @@ const makeDesktopRunId = Crypto.Crypto.pipe( Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), ); -class DesktopBackendPortUnavailableError extends Data.TaggedError( +export class DesktopBackendPortUnavailableError extends Schema.TaggedErrorClass()( "DesktopBackendPortUnavailableError", -)<{ - readonly startPort: number; - readonly maxPort: number; - readonly hosts: readonly string[]; -}> { - override get message() { + { + startPort: Schema.Int, + maxPort: Schema.Int, + hosts: Schema.Array(Schema.String), + }, +) { + override get message(): string { return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; } } -class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( +export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedErrorClass()( "DesktopDevelopmentBackendPortRequiredError", -)<{}> { - override get message() { + {}, +) { + override get message(): string { return "T3CODE_PORT is required in desktop development."; } } @@ -100,12 +103,12 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr ): Effect.fn.Return< void, never, - | DesktopLifecycle.DesktopShutdown + | DesktopShutdown.DesktopShutdown | DesktopState.DesktopState | ElectronApp.ElectronApp | ElectronDialog.ElectronDialog > { - const shutdown = yield* DesktopLifecycle.DesktopShutdown; + const shutdown = yield* DesktopShutdown.DesktopShutdown; const state = yield* DesktopState.DesktopState; const electronApp = yield* ElectronApp.ElectronApp; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -163,6 +166,16 @@ const bootstrap = Effect.gen(function* () { } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const rendererTarget = environment.isDevelopment + ? Option.getOrThrow(environment.devServerUrl) + : backendConfig.httpBaseUrl; + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + targetOrigin: rendererTarget, + backendOrigin: backendConfig.httpBaseUrl, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, }); @@ -189,9 +202,8 @@ const startup = Effect.gen(function* () { const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const electronApp = yield* ElectronApp.ElectronApp; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; + const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; @@ -209,7 +221,7 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* lifecycle.register; - yield* cloudAuth.configure; + yield* clerk.configure; yield* electronApp.whenReady.pipe( Effect.withSpan("desktop.electron.whenReady"), @@ -218,7 +230,6 @@ const startup = Effect.gen(function* () { yield* logStartupInfo("app ready"); yield* appIdentity.configure; yield* applicationMenu.configure; - yield* electronProtocol.registerDesktopFileProtocol; yield* updates.configure; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); @@ -229,7 +240,7 @@ const scopedProgram = Effect.scoped( yield* Effect.annotateLogsScoped({ scope: "desktop", runId }); yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); - const shutdown = yield* DesktopLifecycle.DesktopShutdown; + const shutdown = yield* DesktopShutdown.DesktopShutdown; const backendManager = yield* DesktopBackendManager.DesktopBackendManager; yield* Effect.addFinalizer(() => diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts new file mode 100644 index 00000000000..666c36d391d --- /dev/null +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -0,0 +1,30 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + DesktopBackendPortUnavailableError, + DesktopDevelopmentBackendPortRequiredError, +} from "./DesktopApp.ts"; + +describe("DesktopApp errors", () => { + it("preserves unavailable backend port context", () => { + const error = new DesktopBackendPortUnavailableError({ + startPort: 3_773, + maxPort: 65_535, + hosts: ["127.0.0.1", "0.0.0.0", "::"], + }); + + assert.equal(error.startPort, 3_773); + assert.equal(error.maxPort, 65_535); + assert.deepEqual(error.hosts, ["127.0.0.1", "0.0.0.0", "::"]); + assert.equal( + error.message, + "No desktop backend port is available on hosts 127.0.0.1, 0.0.0.0, :: between 3773 and 65535.", + ); + }); + + it("reports the required development port", () => { + const error = new DesktopDevelopmentBackendPortRequiredError(); + + assert.equal(error.message, "T3CODE_PORT is required in desktop development."); + }); +}); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index eafdbf056dc..3c95b266bc1 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -4,6 +4,7 @@ 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 PlatformError from "effect/PlatformError"; import type * as Electron from "electron"; @@ -63,7 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => }), appendCommandLineSwitch: () => Effect.void, on: () => Effect.void, - } satisfies ElectronApp.ElectronAppShape); + } satisfies ElectronApp.ElectronApp["Service"]); const makeAssetsLayer = (png: Option.Option) => Layer.succeed(DesktopAssets.DesktopAssets, { @@ -73,7 +74,7 @@ const makeAssetsLayer = (png: Option.Option) => png, }), resolveResourcePath: () => Effect.succeed(Option.none()), - } satisfies DesktopAssets.DesktopAssetsShape); + } satisfies DesktopAssets.DesktopAssets["Service"]); const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { const { env, ...environmentOverrides } = overrides; @@ -105,6 +106,7 @@ const withIdentity = ( readonly calls?: ElectronAppCalls; readonly environment?: TestEnvironmentInput; readonly legacyPathExists?: boolean; + readonly legacyPathProbeError?: PlatformError.PlatformError; readonly packageJson?: string; readonly pngIconPath?: Option.Option; } = {}, @@ -121,7 +123,11 @@ const withIdentity = ( Layer.provideMerge( FileSystem.layerNoop({ exists: (path) => - Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")), + input.legacyPathProbeError + ? Effect.fail(input.legacyPathProbeError) + : Effect.succeed( + input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), + ), readFileString: () => Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), }), @@ -147,6 +153,33 @@ describe("DesktopAppIdentity", () => { ), ); + it.effect("preserves failures while inspecting the legacy userData path", () => { + const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + description: "permission denied", + pathOrDescriptor: legacyPath, + }); + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); + + assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError); + assert.equal(error.legacyPath, legacyPath); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to inspect legacy desktop user-data path at "${legacyPath}".`, + ); + }), + { legacyPathProbeError: cause }, + ); + }); + it.effect("configures app identity from the environment commit override", () => { const calls: ElectronAppCalls = { setAboutPanelOptions: [], diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 52f4b12808e..385e694338d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,14 +18,24 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); -export interface DesktopAppIdentityShape { - readonly resolveUserDataPath: Effect.Effect; - readonly configure: Effect.Effect; +export class DesktopUserDataPathResolutionError extends Schema.TaggedErrorClass()( + "DesktopUserDataPathResolutionError", + { + legacyPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`; + } } export class DesktopAppIdentity extends Context.Service< DesktopAppIdentity, - DesktopAppIdentityShape + { + readonly resolveUserDataPath: Effect.Effect; + readonly configure: Effect.Effect; + } >()("@t3tools/desktop/app/DesktopAppIdentity") {} const normalizeCommitHash = (value: string): Option.Option => { @@ -35,7 +45,7 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -85,9 +95,15 @@ const make = Effect.gen(function* () { environment.appDataDirectory, environment.legacyUserDataDirName, ); - const legacyPathExists = yield* fileSystem - .exists(legacyPath) - .pipe(Effect.orElseSucceed(() => false)); + const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathResolutionError({ + legacyPath, + cause, + }), + ), + ); return legacyPathExists ? legacyPath : environment.path.join(environment.appDataDirectory, environment.userDataDirName); diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts new file mode 100644 index 00000000000..2eb55c72057 --- /dev/null +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -0,0 +1,57 @@ +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 Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; + +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))); + +describe("DesktopAssets", () => { + it.effect("preserves the failed asset candidate and filesystem cause", () => + Effect.gen(function* () { + const fileName = "custom.bin"; + const candidatePath = "/repo/apps/desktop/resources/custom.bin"; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: candidatePath, + description: "private filesystem diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + exists: (path) => (path === candidatePath ? Effect.fail(cause) : Effect.succeed(false)), + }); + const assetsLayer = DesktopAssets.layer.pipe( + Layer.provide(Layer.merge(fileSystemLayer, environmentLayer)), + ); + const assets = yield* DesktopAssets.DesktopAssets.pipe(Effect.provide(assetsLayer)); + + const error = yield* assets.resolveResourcePath(fileName).pipe(Effect.flip); + + assert.instanceOf(error, DesktopAssets.DesktopAssetProbeError); + assert.equal(error.fileName, fileName); + assert.equal(error.candidatePath, candidatePath); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to probe desktop asset "${fileName}" at ${candidatePath}.`, + ); + assert.notInclude(error.message, "private filesystem diagnostic"); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index 3b5a15e435f..95585acab74 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -3,6 +3,7 @@ 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 Schema from "effect/Schema"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -12,27 +13,47 @@ export interface DesktopIconPaths { readonly png: Option.Option; } -export interface DesktopAssetsShape { - readonly iconPaths: Effect.Effect; - readonly resolveResourcePath: (fileName: string) => Effect.Effect>; +export class DesktopAssetProbeError extends Schema.TaggedErrorClass()( + "DesktopAssetProbeError", + { + fileName: Schema.String, + candidatePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to probe desktop asset "${this.fileName}" at ${this.candidatePath}.`; + } } -export class DesktopAssets extends Context.Service()( - "@t3tools/desktop/app/DesktopAssets", -) {} +export class DesktopAssets extends Context.Service< + DesktopAssets, + { + readonly iconPaths: Effect.Effect; + readonly resolveResourcePath: ( + fileName: string, + ) => Effect.Effect, DesktopAssetProbeError>; + } +>()("@t3tools/desktop/app/DesktopAssets") {} const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* ( fileName: string, ): Effect.fn.Return< Option.Option, - never, + DesktopAssetProbeError, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const candidates = environment.resolveResourcePathCandidates(fileName); for (const candidate of candidates) { - const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + const exists = yield* fileSystem + .exists(candidate) + .pipe( + Effect.mapError( + (cause) => new DesktopAssetProbeError({ fileName, candidatePath: candidate, cause }), + ), + ); if (exists) { return Option.some(candidate); } @@ -44,16 +65,23 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( ext: keyof DesktopIconPaths, ): Effect.fn.Return< Option.Option, - never, + DesktopAssetProbeError, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") { const developmentDockIconPath = environment.developmentDockIconPath; - const developmentDockIconExists = yield* fileSystem - .exists(developmentDockIconPath) - .pipe(Effect.orElseSucceed(() => false)); + const developmentDockIconExists = yield* fileSystem.exists(developmentDockIconPath).pipe( + Effect.mapError( + (cause) => + new DesktopAssetProbeError({ + fileName: "icon.png", + candidatePath: developmentDockIconPath, + cause, + }), + ), + ); if (developmentDockIconExists) { return Option.some(developmentDockIconPath); } @@ -62,7 +90,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( return yield* resolveResourcePath(`icon.${ext}`); }); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const context = yield* Effect.context< FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment >(); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.test.ts b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts new file mode 100644 index 00000000000..18bba9486cb --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts @@ -0,0 +1,122 @@ +import { assert, describe, it } from "@effect/vitest"; +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 Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import * as DesktopBackendOutputLog from "./DesktopBackendOutputLog.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const LOG_FILE_PATH = "/Users/alice/.t3/userdata/logs/server-child.log"; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +}).pipe(Layer.provide(Layer.merge(Path.layer, DesktopConfig.layerTest({})))); + +const withOutputLog = ( + effect: Effect.Effect, + fileSystemLayer: Layer.Layer, + messages: Array>, +) => { + const logger = Logger.make(({ message }) => { + messages.push(Array.isArray(message) ? message : [message]); + }); + const outputLogLayer = DesktopBackendOutputLog.layer.pipe( + Layer.provide(Layer.mergeAll(fileSystemLayer, Path.layer, environmentLayer)), + Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), + ); + return effect.pipe(Effect.provide(outputLogLayer)); +}; + +const loggedError = (messages: ReadonlyArray>): unknown => + messages.flat().find((value) => typeof value === "object" && value !== null && "error" in value) + ?.error; + +describe("DesktopBackendOutputLog", () => { + it.effect("logs setup failures with the log path and exact cause", () => { + const messages: Array> = []; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: "/Users/alice/.t3/userdata/logs", + description: "private setup diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.fail(cause), + }); + + return withOutputLog( + Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); + + const error = loggedError(messages); + assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogSetupError); + assert.equal(error.logFilePath, LOG_FILE_PATH); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to initialize the desktop backend output log at ${LOG_FILE_PATH}.`, + ); + assert.notInclude(error.message, "private setup diagnostic"); + }), + fileSystemLayer, + messages, + ); + }); + + it.effect("logs record write failures with the operation and exact cause", () => { + const messages: Array> = []; + const missingCause = PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "stat", + pathOrDescriptor: LOG_FILE_PATH, + }); + const writeCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFile", + pathOrDescriptor: LOG_FILE_PATH, + description: "private write diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.void, + stat: () => Effect.fail(missingCause), + readDirectory: () => Effect.succeed([]), + writeFile: () => Effect.fail(writeCause), + }); + + return withOutputLog( + Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); + + const error = loggedError(messages); + assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogWriteError); + assert.equal(error.operation, "write-record"); + assert.equal(error.logFilePath, LOG_FILE_PATH); + assert.strictEqual(error.cause, writeCause); + assert.equal( + error.message, + `Desktop backend output log operation "write-record" failed at ${LOG_FILE_PATH}.`, + ); + assert.notInclude(error.message, "private write diagnostic"); + }), + fileSystemLayer, + messages, + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.ts b/apps/desktop/src/app/DesktopBackendOutputLog.ts new file mode 100644 index 00000000000..cad83229deb --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendOutputLog.ts @@ -0,0 +1,385 @@ +import * as Context from "effect/Context"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; +export const DESKTOP_LOG_FILE_MAX_FILES = 10; + +const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; + +interface RotatingLogFileWriter { + readonly filePath: string; + readonly writeBytes: ( + chunk: Uint8Array, + ) => Effect.Effect; + readonly writeText: ( + chunk: string, + ) => Effect.Effect; +} + +class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterConfigurationError", + { + option: Schema.Literals(["maxBytes", "maxFiles"]), + value: Schema.Number, + }, +) { + override get message(): string { + return `${this.option} must be >= 1 (received ${this.value})`; + } +} + +class DesktopLogFileWriterRecoveryError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterRecoveryError", + { + logFilePath: Schema.String, + cause: Schema.Defect(), + recoveryCause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to refresh desktop backend output log size after a write failure at ${this.logFilePath}.`; + } +} + +export class DesktopBackendOutputLogSetupError extends Schema.TaggedErrorClass()( + "DesktopBackendOutputLogSetupError", + { + logFilePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the desktop backend output log at ${this.logFilePath}.`; + } +} + +export class DesktopBackendOutputLogWriteError extends Schema.TaggedErrorClass()( + "DesktopBackendOutputLogWriteError", + { + operation: Schema.Literals(["encode-record", "write-record"]), + logFilePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop backend output log operation "${this.operation}" failed at ${this.logFilePath}.`; + } +} + +export class DesktopBackendConsoleWriteError extends Schema.TaggedErrorClass()( + "DesktopBackendConsoleWriteError", + { + streamName: Schema.Literals(["stdout", "stderr"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to mirror desktop backend output to ${this.streamName}.`; + } +} + +export class DesktopBackendOutputLog extends Context.Service< + DesktopBackendOutputLog, + { + readonly writeSessionBoundary: (input: { + readonly phase: "START" | "END"; + readonly details: string; + }) => Effect.Effect; + readonly writeOutputChunk: ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, + ) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopBackendOutputLog") {} + +type DesktopLogFileWriterError = + | DesktopLogFileWriterConfigurationError + | PlatformError.PlatformError; + +const DesktopBackendChildLogRecord = Schema.Struct({ + message: Schema.String, + level: Schema.Literals(["INFO", "ERROR"]), + timestamp: Schema.String, + annotations: Schema.Record(Schema.String, Schema.Unknown), + spans: Schema.Record(Schema.String, Schema.Unknown), + fiberId: Schema.String, +}); + +const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( + Schema.fromJsonString(DesktopBackendChildLogRecord), +); + +const DesktopBackendOutputLogNoop: DesktopBackendOutputLog["Service"] = { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, +}; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const currentDesktopRunId = Effect.gen(function* () { + const annotations = yield* References.CurrentLogAnnotations; + const runId = annotations.runId; + return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; +}); + +const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); + +const refreshFileSize = ( + fileSystem: FileSystem.FileSystem, + filePath: string, +): Effect.Effect => + fileSystem.stat(filePath).pipe( + Effect.map((stat) => Number(stat.size)), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(error), + }), + ); + +const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { + readonly filePath: string; + readonly maxBytes?: number; + readonly maxFiles?: number; +}): Effect.fn.Return< + RotatingLogFileWriter, + DesktopLogFileWriterError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; + const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; + const directory = path.dirname(input.filePath); + const baseName = path.basename(input.filePath); + + if (maxBytes < 1) { + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxBytes", + value: maxBytes, + }); + } + if (maxFiles < 1) { + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxFiles", + value: maxFiles, + }); + } + + yield* fileSystem.makeDirectory(directory, { recursive: true }); + + const withSuffix = (index: number) => `${input.filePath}.${index}`; + const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); + const mutex = yield* Semaphore.make(1); + + const recoverCurrentSize = ( + cause: PlatformError.PlatformError, + ): Effect.Effect => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.matchEffect({ + onFailure: (recoveryCause) => + Effect.fail( + new DesktopLogFileWriterRecoveryError({ + logFilePath: input.filePath, + cause, + recoveryCause, + }), + ), + onSuccess: (size) => Ref.set(currentSize, size).pipe(Effect.andThen(Effect.fail(cause))), + }), + ); + + const pruneOverflowBackups = Effect.gen(function* () { + const entries = yield* fileSystem.readDirectory(directory); + for (const entry of entries) { + if (!entry.startsWith(`${baseName}.`)) continue; + const suffix = Number(entry.slice(baseName.length + 1)); + if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; + yield* fileSystem.remove(path.join(directory, entry), { force: true }); + } + }); + + const rotate = Effect.gen(function* () { + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }); + for (let index = maxFiles - 1; index >= 1; index -= 1) { + const source = withSuffix(index); + const sourceExists = yield* fileSystem.exists(source); + if (sourceExists) { + yield* fileSystem.rename(source, withSuffix(index + 1)); + } + } + const currentExists = yield* fileSystem.exists(input.filePath); + if (currentExists) { + yield* fileSystem.rename(input.filePath, withSuffix(1)); + } + yield* Ref.set(currentSize, 0); + }); + + const writeBytes = ( + chunk: Uint8Array, + ): Effect.Effect => { + if (chunk.byteLength === 0) return Effect.void; + + return mutex.withPermits(1)( + Effect.gen(function* () { + const beforeSize = yield* Ref.get(currentSize); + if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { + yield* rotate; + } + + yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); + const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; + yield* Ref.set(currentSize, afterSize); + + if (afterSize > maxBytes) { + yield* rotate; + } + }).pipe( + Effect.catchTags({ + PlatformError: recoverCurrentSize, + }), + ), + ); + }; + + yield* pruneOverflowBackups; + + return { + filePath: input.filePath, + writeBytes, + writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), + } satisfies RotatingLogFileWriter; +}); + +const writeDevelopmentConsoleOutput = ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, +): Effect.Effect => + Effect.try({ + try: () => { + const output = streamName === "stderr" ? process.stderr : process.stdout; + output.write(chunk); + }, + catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }), + }).pipe( + Effect.catchTags({ + DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }), + }), + ); + +const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( + function* ( + logFile: RotatingLogFileWriter, + input: { + readonly message: string; + readonly level: "INFO" | "ERROR"; + readonly annotations: Record; + }, + ): Effect.fn.Return { + return yield* Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + const encoded = yield* encodeDesktopBackendChildLogRecord({ + message: input.message, + level: input.level, + timestamp, + annotations: input.annotations, + spans: {}, + fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, + }).pipe( + Effect.mapError( + (cause) => + new DesktopBackendOutputLogWriteError({ + operation: "encode-record", + logFilePath: logFile.filePath, + cause, + }), + ), + ); + yield* logFile.writeText(`${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopBackendOutputLogWriteError({ + operation: "write-record", + logFilePath: logFile.filePath, + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + DesktopBackendOutputLogWriteError: (error) => Effect.logError(error.message, { error }), + }), + ); + }, +); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const logFilePath = environment.path.join(environment.logDir, "server-child.log"); + const writer = yield* makeRotatingLogFileWriter({ + filePath: logFilePath, + }).pipe( + Effect.mapError((cause) => new DesktopBackendOutputLogSetupError({ logFilePath, cause })), + Effect.map(Option.some), + Effect.catchTags({ + DesktopBackendOutputLogSetupError: (error) => + Effect.logError(error.message, { error }).pipe(Effect.as(Option.none())), + }), + ); + + const service = Option.match(writer, { + onNone: () => DesktopBackendOutputLogNoop, + onSome: (logFile) => + ({ + writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( + function* ({ phase, details }) { + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: `backend child process session ${phase.toLowerCase()}`, + level: "INFO", + annotations: { + component: "desktop-backend-child", + runId, + phase, + details: sanitizeLogValue(details), + }, + }); + }, + ), + writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( + function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId, + stream: streamName, + text: textDecoder.decode(chunk), + }, + }); + }, + ), + }) satisfies DesktopBackendOutputLog["Service"], + }); + + return DesktopBackendOutputLog.of(service); +}); + +export const layer = Layer.effect(DesktopBackendOutputLog, make); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts new file mode 100644 index 00000000000..9b5ed56d1f3 --- /dev/null +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -0,0 +1,149 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ + createClerkBridgeMock: vi.fn(), + storageAdapter: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + storageMock: vi.fn(), +})); + +vi.mock("@clerk/electron", () => ({ + createClerkBridge: createClerkBridgeMock, +})); + +vi.mock("@clerk/electron/storage", () => ({ + storage: storageMock, +})); + +import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const makeDesktopClerkLayer = (isDevelopment = true) => { + const environment = DesktopEnvironment.DesktopEnvironment.of({ + stateDir: "/tmp/t3-state", + isDevelopment, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + + return DesktopClerk.layer.pipe( + Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + ); +}; + +describe("DesktopClerk", () => { + beforeEach(() => { + createClerkBridgeMock.mockReset(); + storageMock.mockReset(); + }); + + it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { + const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; + + assert.equal( + DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey), + "clerk.t3.codes", + ); + assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined); + assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined); + }); + + it.effect("acquires and releases the SDK bridge with the layer", () => { + const cleanup = vi.fn(); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup }); + + return Effect.gen(function* () { + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); + + assert.deepEqual(createClerkBridgeMock.mock.calls, [ + [ + { + storage: storageAdapter, + passkeys: true, + renderer: { scheme: "t3code-dev", host: "app" }, + }, + ], + ]); + assert.equal(cleanup.mock.calls.length, 1); + storageMock.mockClear(); + createClerkBridgeMock.mockClear(); + }); + }); + + it.effect("preserves bridge initialization failures", () => { + const cause = new Error("bridge initialization failed"); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockImplementationOnce(() => { + throw cause; + }); + + return Effect.gen(function* () { + const error = yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())).pipe(Effect.flip); + + assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeInitializationError); + assert.equal(error.stateDir, "/tmp/t3-state"); + assert.equal(error.isDevelopment, true); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + 'Failed to initialize the desktop Clerk bridge for state directory "/tmp/t3-state" (development: true).', + ); + }); + }); + + it.effect("preserves bridge cleanup failures", () => { + const cause = new Error("bridge cleanup failed"); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ + cleanup: () => { + throw cause; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(Effect.scoped(Layer.build(makeDesktopClerkLayer(false)))); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeCleanupError); + assert.equal(error.stateDir, "/tmp/t3-state"); + assert.equal(error.isDevelopment, false); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + 'Failed to clean up the desktop Clerk bridge for state directory "/tmp/t3-state" (development: false).', + ); + } + }); + }); + + it.each([ + { isDevelopment: true, scheme: "t3code-dev" }, + { isDevelopment: false, scheme: "t3code" }, + ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { + const bridge = { cleanup: vi.fn() }; + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue(bridge); + + assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); + assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]); + assert.deepEqual(createClerkBridgeMock.mock.calls, [ + [ + { + storage: storageAdapter, + passkeys: true, + renderer: { scheme, host: "app" }, + }, + ], + ]); + storageMock.mockClear(); + createClerkBridgeMock.mockClear(); + }); +}); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts new file mode 100644 index 00000000000..0e283f8dd0c --- /dev/null +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -0,0 +1,135 @@ +import { createClerkBridge } from "@clerk/electron"; +import { storage } from "@clerk/electron/storage"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; + +export class DesktopClerkBridgeInitializationError extends Schema.TaggedErrorClass()( + "DesktopClerkBridgeInitializationError", + { + stateDir: Schema.String, + isDevelopment: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; + } +} + +export class DesktopClerkBridgeCleanupError extends Schema.TaggedErrorClass()( + "DesktopClerkBridgeCleanupError", + { + stateDir: Schema.String, + isDevelopment: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to clean up the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; + } +} + +export class DesktopClerk extends Context.Service< + DesktopClerk, + { + readonly configure: Effect.Effect< + void, + never, + ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + >; + } +>()("@t3tools/desktop/app/DesktopClerk") {} + +export function resolveDesktopClerkFrontendApiHostname( + publishableKey: string | undefined, +): string | undefined { + const normalizedKey = publishableKey?.trim(); + if (!normalizedKey) return undefined; + + try { + return clerkFrontendApiHostnameFromPublishableKey(normalizedKey); + } catch { + return undefined; + } +} + +export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname( + typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" + ? undefined + : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, +); + +export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { + return createClerkBridge({ + storage: storage({ path: stateDir }), + passkeys: true, + renderer: { + scheme: ElectronProtocol.getDesktopScheme(isDevelopment), + host: ElectronProtocol.DESKTOP_HOST, + }, + }); +} + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + yield* Effect.acquireRelease( + Effect.try({ + try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + catch: (cause) => + new DesktopClerkBridgeInitializationError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }), + (bridge) => + Effect.try({ + try: () => bridge.cleanup(), + catch: (cause) => + new DesktopClerkBridgeCleanupError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }).pipe(Effect.orDie), + ); + + return DesktopClerk.of({ + configure: Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + if (!(yield* electronApp.requestSingleInstanceLock)) { + yield* electronApp.quit; + return yield* Effect.interrupt; + } + + yield* electronApp.on("second-instance", () => { + void runPromise( + Effect.gen(function* () { + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(mainWindow)) { + yield* electronWindow.reveal(mainWindow.value); + } + }), + ); + }); + }).pipe(Effect.withSpan("desktop.clerk.configure")), + }); +}); + +export const layer = Layer.effect(DesktopClerk, make); diff --git a/apps/desktop/src/app/DesktopCloudAuth.test.ts b/apps/desktop/src/app/DesktopCloudAuth.test.ts deleted file mode 100644 index 002fd86b0a4..00000000000 --- a/apps/desktop/src/app/DesktopCloudAuth.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as ElectronApp from "../electron/ElectronApp.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopCloudAuth from "./DesktopCloudAuth.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -interface CloudAuthHarness { - readonly app: ElectronApp.ElectronAppShape; - readonly window: ElectronWindow.ElectronWindowShape; - readonly listeners: Map void)[]>; - readonly protocolRegistrations: { - readonly protocol: string; - readonly path?: string; - readonly args?: readonly string[]; - }[]; - readonly sends: { readonly channel: string; readonly args: readonly unknown[] }[]; - readonly reveals: unknown[]; - readonly layer: Layer.Layer< - | DesktopCloudAuth.DesktopCloudAuth - | DesktopEnvironment.DesktopEnvironment - | ElectronApp.ElectronApp - | ElectronWindow.ElectronWindow - >; -} - -function makeHarness(input: { readonly isDevelopment: boolean }): CloudAuthHarness { - const listeners = new Map void)[]>(); - const protocolRegistrations: CloudAuthHarness["protocolRegistrations"] = []; - const sends: CloudAuthHarness["sends"] = []; - const reveals: unknown[] = []; - const mainWindow = { id: "main-window" }; - - const app = ElectronApp.ElectronApp.of({ - metadata: Effect.succeed({ - appVersion: "0.0.0-test", - appPath: "/tmp/t3-code-test", - isPackaged: !input.isDevelopment, - resourcesPath: "/tmp/t3-code-test/resources", - runningUnderArm64Translation: false, - }), - name: Effect.succeed("T3 Code"), - whenReady: Effect.void, - quit: Effect.void, - exit: () => Effect.void, - relaunch: () => Effect.void, - setPath: () => Effect.void, - setName: () => Effect.void, - setAboutPanelOptions: () => Effect.void, - setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), - isDefaultProtocolClient: () => Effect.succeed(false), - setAsDefaultProtocolClient: (protocol, path, args) => - Effect.sync(() => { - protocolRegistrations.push({ - protocol, - ...(path === undefined ? {} : { path }), - ...(args === undefined ? {} : { args }), - }); - return true; - }), - setDesktopName: () => Effect.void, - setDockIcon: () => Effect.void, - appendCommandLineSwitch: () => Effect.void, - on: (eventName, listener) => - Effect.sync(() => { - const erasedListener = listener as (...args: readonly unknown[]) => void; - listeners.set(eventName, [...(listeners.get(eventName) ?? []), erasedListener]); - }), - }); - - const window = ElectronWindow.ElectronWindow.of({ - create: () => Effect.die("not used"), - main: Effect.succeed(Option.some(mainWindow as never)), - currentMainOrFirst: Effect.succeed(Option.some(mainWindow as never)), - focusedMainOrFirst: Effect.succeed(Option.some(mainWindow as never)), - setMain: () => Effect.void, - clearMain: () => Effect.void, - reveal: (target) => - Effect.sync(() => { - reveals.push(target); - }), - sendAll: (channel, ...args) => - Effect.sync(() => { - sends.push({ channel, args }); - }), - destroyAll: Effect.void, - syncAllAppearance: () => Effect.void, - }); - - const environment = DesktopEnvironment.DesktopEnvironment.of({ - isDevelopment: input.isDevelopment, - } as DesktopEnvironment.DesktopEnvironmentShape); - const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment); - - return { - app, - window, - listeners, - protocolRegistrations, - sends, - reveals, - layer: Layer.mergeAll( - DesktopCloudAuth.layer.pipe( - Layer.provideMerge(environmentLayer), - Layer.provide(NodeServices.layer), - ), - Layer.succeed(ElectronApp.ElectronApp, app), - Layer.succeed(ElectronWindow.ElectronWindow, window), - ), - }; -} - -function emitAppEvent( - harness: CloudAuthHarness, - eventName: string, - ...args: readonly unknown[] -): void { - for (const listener of harness.listeners.get(eventName) ?? []) { - listener(...args); - } -} - -const flushCloudAuthDispatch = Effect.promise(() => Promise.resolve()); - -describe("DesktopCloudAuth", () => { - it("uses separate callback schemes for packaged and development builds", () => { - assert.equal( - DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: false }), - "t3code", - ); - assert.equal( - DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: true }), - "t3code-dev", - ); - }); - - it("builds a native callback URL with request state", () => { - assert.equal( - DesktopCloudAuth.buildCloudAuthCallbackUrl({ - scheme: "t3code", - state: "state-1", - }), - "t3code://auth/callback?t3_state=state-1", - ); - }); - - it("accepts only the expected scheme, host, path, and state", () => { - assert.isNotNull( - DesktopCloudAuth.parseCloudAuthCallbackUrl({ - rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=state-1", - scheme: "t3code", - state: "state-1", - }), - ); - assert.isNull( - DesktopCloudAuth.parseCloudAuthCallbackUrl({ - rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=wrong", - scheme: "t3code", - state: "state-1", - }), - ); - assert.isNull( - DesktopCloudAuth.parseCloudAuthCallbackUrl({ - rawUrl: "https://example.com/callback?rotating_token_nonce=nonce&t3_state=state-1", - scheme: "t3code", - state: "state-1", - }), - ); - }); - - it("builds a native development callback URL with request state", () => { - assert.equal( - DesktopCloudAuth.buildCloudAuthCallbackUrl({ - scheme: "t3code-dev", - state: "state-1", - }), - "t3code-dev://auth/callback?t3_state=state-1", - ); - }); - - it.effect("registers the development protocol client and dispatches matching callbacks", () => { - const harness = makeHarness({ isDevelopment: true }); - - return Effect.gen(function* () { - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; - yield* cloudAuth.configure; - const redirectUrl = yield* cloudAuth.createRequest; - const callbackUrl = new URL(redirectUrl); - callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1"); - - let prevented = false; - emitAppEvent( - harness, - "open-url", - { preventDefault: () => (prevented = true) }, - callbackUrl.toString(), - ); - yield* flushCloudAuthDispatch; - - assert.isTrue(prevented); - assert.deepEqual( - harness.protocolRegistrations.map((registration) => registration.protocol), - ["t3code-dev"], - ); - assert.isString(harness.protocolRegistrations[0]?.path); - assert.isArray(harness.protocolRegistrations[0]?.args); - assert.deepEqual(harness.sends, [ - { - channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, - args: [callbackUrl.toString()], - }, - ]); - assert.lengthOf(harness.reveals, 1); - }).pipe(Effect.provide(harness.layer), Effect.scoped); - }); - - it.effect("rejects mismatched callback state and only consumes the pending request once", () => { - const harness = makeHarness({ isDevelopment: false }); - - return Effect.gen(function* () { - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; - yield* cloudAuth.configure; - const redirectUrl = yield* cloudAuth.createRequest; - const validCallback = new URL(redirectUrl); - validCallback.searchParams.set("rotating_token_nonce", "nonce-1"); - const invalidCallback = new URL(validCallback); - invalidCallback.searchParams.set(DesktopCloudAuth.CLOUD_AUTH_CALLBACK_STATE_PARAM, "wrong"); - - emitAppEvent( - harness, - "open-url", - { preventDefault: () => undefined }, - invalidCallback.toString(), - ); - yield* flushCloudAuthDispatch; - assert.deepEqual(harness.sends, []); - - emitAppEvent( - harness, - "open-url", - { preventDefault: () => undefined }, - validCallback.toString(), - ); - yield* flushCloudAuthDispatch; - emitAppEvent( - harness, - "open-url", - { preventDefault: () => undefined }, - validCallback.toString(), - ); - yield* flushCloudAuthDispatch; - - assert.deepEqual( - harness.protocolRegistrations.map((registration) => registration.protocol), - ["t3code"], - ); - assert.deepEqual(harness.sends, [ - { - channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, - args: [validCallback.toString()], - }, - ]); - }).pipe(Effect.provide(harness.layer), Effect.scoped); - }); - - it.effect( - "routes second-instance callbacks and reveals the window for non-callback launches", - () => { - const harness = makeHarness({ isDevelopment: true }); - - return Effect.gen(function* () { - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; - yield* cloudAuth.configure; - const redirectUrl = yield* cloudAuth.createRequest; - const callbackUrl = new URL(redirectUrl); - callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1"); - - emitAppEvent(harness, "second-instance", {}, ["electron", callbackUrl.toString()]); - yield* flushCloudAuthDispatch; - - const revealCountAfterCallback = harness.reveals.length; - emitAppEvent(harness, "second-instance", {}, ["electron", "--opened-from-dock"]); - yield* flushCloudAuthDispatch; - - assert.deepEqual(harness.sends, [ - { - channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, - args: [callbackUrl.toString()], - }, - ]); - assert.equal(revealCountAfterCallback, 1); - assert.equal(harness.reveals.length, 2); - }).pipe(Effect.provide(harness.layer), Effect.scoped); - }, - ); -}); diff --git a/apps/desktop/src/app/DesktopCloudAuth.ts b/apps/desktop/src/app/DesktopCloudAuth.ts deleted file mode 100644 index 732de27b9ab..00000000000 --- a/apps/desktop/src/app/DesktopCloudAuth.ts +++ /dev/null @@ -1,330 +0,0 @@ -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Scope from "effect/Scope"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; - -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as ElectronApp from "../electron/ElectronApp.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import type * as Electron from "electron"; - -export const CLOUD_AUTH_CALLBACK_HOST = "auth"; -export const CLOUD_AUTH_CALLBACK_PATHNAME = "/callback"; -export const CLOUD_AUTH_CALLBACK_STATE_PARAM = "t3_state"; -export const CLOUD_AUTH_CALLBACK_SCHEME = "t3code"; -export const DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME = "t3code-dev"; - -const CLOUD_AUTH_REQUEST_TIMEOUT_MS = 5 * 60 * 1000; - -export class DesktopCloudAuthCallbackServerError extends Data.TaggedError( - "DesktopCloudAuthCallbackServerError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Failed to start the desktop cloud auth callback server."; - } -} - -interface PendingCloudAuthRequest { - readonly state: string; - readonly redirectUrl: string; - readonly close: () => void; -} - -export interface DesktopCloudAuthShape { - readonly createRequest: Effect.Effect; - readonly configure: Effect.Effect< - void, - never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope - >; -} - -export class DesktopCloudAuth extends Context.Service()( - "@t3tools/desktop/app/DesktopCloudAuth", -) {} - -export function resolveCloudAuthCallbackScheme(input: { readonly isDevelopment: boolean }): string { - return input.isDevelopment ? DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME : CLOUD_AUTH_CALLBACK_SCHEME; -} - -export function buildCloudAuthCallbackUrl(input: { - readonly scheme: string; - readonly state: string; -}): string { - const url = new URL( - `${input.scheme}://${CLOUD_AUTH_CALLBACK_HOST}${CLOUD_AUTH_CALLBACK_PATHNAME}`, - ); - url.searchParams.set(CLOUD_AUTH_CALLBACK_STATE_PARAM, input.state); - return url.toString(); -} - -export function parseCloudAuthCallbackUrl(input: { - readonly rawUrl: unknown; - readonly scheme: string; - readonly state: string; -}): URL | null { - if (typeof input.rawUrl !== "string") { - return null; - } - - try { - const url = new URL(input.rawUrl); - if (url.protocol !== `${input.scheme}:`) return null; - if (url.hostname !== CLOUD_AUTH_CALLBACK_HOST) return null; - if (url.pathname !== CLOUD_AUTH_CALLBACK_PATHNAME) return null; - if (url.searchParams.get(CLOUD_AUTH_CALLBACK_STATE_PARAM) !== input.state) return null; - return url; - } catch { - return null; - } -} - -export function findCloudAuthCallbackUrl(input: { - readonly values: readonly unknown[]; - readonly scheme: string; - readonly state: string; -}): URL | null { - for (const value of input.values) { - const url = parseCloudAuthCallbackUrl({ - rawUrl: value, - scheme: input.scheme, - state: input.state, - }); - if (url) return url; - } - return null; -} - -export function resolveProtocolClientLaunchArgs(input: { - readonly argv: readonly string[]; -}): readonly string[] { - return input.argv.slice(1); -} - -function resolveConfiguredProtocolClient(): { - readonly path: string; - readonly args: readonly string[]; -} | null { - const path = process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_PATH?.trim(); - if (!path) return null; - - return { - path, - args: (process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_ARGS ?? "") - .split("\n") - .map((arg) => arg.trim()) - .filter((arg) => arg.length > 0), - }; -} - -function isProtocolRegistrationManagedExternally(): boolean { - return process.env.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED?.trim() === "1"; -} - -function resolveProtocolCallbackForwardUrl(): URL | null { - const rawUrl = process.env.T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL?.trim(); - if (!rawUrl) return null; - - try { - const url = new URL(rawUrl); - if (url.protocol !== "http:") return null; - if (url.hostname !== "127.0.0.1") return null; - if (url.pathname !== "/auth/callback") return null; - if (!url.port) return null; - return url; - } catch { - return null; - } -} - -const closeCloudAuthRequest = (request: PendingCloudAuthRequest | null): null => { - request?.close(); - return null; -}; - -function createCloudAuthRequestTimeout(onExpire: () => void): ReturnType { - // @effect-diagnostics-next-line globalTimers:off - Auth request expiry is tied to an Electron callback server, not fiber scheduling. - return setTimeout(onExpire, CLOUD_AUTH_REQUEST_TIMEOUT_MS); -} - -function ignoreCloudAuthCallback(_rawUrl: string) {} - -function startProtocolCallbackForwardServer( - callbackUrl: URL, - dispatch: (rawUrl: string) => void, -): Effect.Effect { - const port = Number.parseInt(callbackUrl.port, 10); - const routesLayer = HttpRouter.add( - "POST", - "/auth/callback", - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const rawUrl = yield* request.text; - yield* Effect.sync(() => { - dispatch(rawUrl); - }); - return HttpServerResponse.empty({ status: 204 }); - }), - ); - - return Effect.gen(function* () { - const NodeHttp = yield* Effect.promise(() => import("node:http")); - const serverLayer = NodeHttpServer.layer(NodeHttp.createServer, { - host: callbackUrl.hostname, - port, - }); - yield* Layer.launch(HttpRouter.serve(routesLayer).pipe(Layer.provideMerge(serverLayer))).pipe( - Effect.forkScoped, - ); - }); -} - -const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - let pendingAuthRequest: PendingCloudAuthRequest | null = null; - let dispatchCloudAuthCallback: (rawUrl: string) => void = ignoreCloudAuthCallback; - const makeCloudAuthRequestState = Effect.gen(function* () { - const [left, right] = yield* Effect.all([crypto.randomUUIDv4, crypto.randomUUIDv4]); - return `${left}${right}`.replaceAll("-", ""); - }); - - return DesktopCloudAuth.of({ - createRequest: Effect.gen(function* () { - const scheme = resolveCloudAuthCallbackScheme({ - isDevelopment: environment.isDevelopment, - }); - const state = yield* makeCloudAuthRequestState.pipe( - Effect.mapError((cause) => new DesktopCloudAuthCallbackServerError({ cause })), - ); - - pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); - - const redirectUrl = buildCloudAuthCallbackUrl({ scheme, state }); - const timeout = createCloudAuthRequestTimeout(() => { - pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); - }); - pendingAuthRequest = { - state, - redirectUrl, - close: () => clearTimeout(timeout), - }; - return redirectUrl; - }), - configure: Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const scope = yield* Scope.Scope; - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); - const scheme = resolveCloudAuthCallbackScheme({ - isDevelopment: environment.isDevelopment, - }); - - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); - }), - ); - - if (isProtocolRegistrationManagedExternally()) { - // Development macOS launchers set the default URL handler before the stock Electron - // process starts so LaunchServices binds the scheme to the worktree-specific app bundle. - } else if (environment.isDevelopment) { - const configuredClient = resolveConfiguredProtocolClient(); - if (configuredClient) { - yield* electronApp.setAsDefaultProtocolClient( - scheme, - configuredClient.path, - configuredClient.args, - ); - } else { - yield* electronApp.setAsDefaultProtocolClient( - scheme, - process.execPath, - resolveProtocolClientLaunchArgs({ argv: process.argv }), - ); - } - } else { - yield* electronApp.setAsDefaultProtocolClient(scheme); - } - - dispatchCloudAuthCallback = (rawUrl: string) => { - const pending = pendingAuthRequest; - const callbackUrl = pending - ? parseCloudAuthCallbackUrl({ rawUrl, scheme, state: pending.state }) - : null; - if (!callbackUrl) { - return; - } - - pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); - void runPromise( - Effect.gen(function* () { - yield* electronWindow.sendAll( - IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, - callbackUrl.toString(), - ); - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); - }; - - const protocolCallbackForwardUrl = resolveProtocolCallbackForwardUrl(); - if (environment.isDevelopment && protocolCallbackForwardUrl) { - yield* startProtocolCallbackForwardServer( - protocolCallbackForwardUrl, - dispatchCloudAuthCallback, - ); - } - - const hasInstanceLock = yield* electronApp.requestSingleInstanceLock; - if (!hasInstanceLock) { - return yield* electronApp.quit; - } - - yield* electronApp.on<[Electron.Event, string]>("open-url", (event, rawUrl) => { - event.preventDefault?.(); - dispatchCloudAuthCallback(rawUrl); - }); - - yield* electronApp.on<[Electron.Event, readonly string[]]>( - "second-instance", - (_event, argv) => { - const values = resolveProtocolClientLaunchArgs({ argv }); - const pending = pendingAuthRequest; - const callbackUrl = pending - ? findCloudAuthCallbackUrl({ values, scheme, state: pending.state }) - : null; - if (callbackUrl) { - dispatchCloudAuthCallback(callbackUrl.toString()); - return; - } - - void runPromise( - Effect.gen(function* () { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); - }, - ); - }).pipe(Effect.withSpan("desktop.cloudAuth.configure")), - }); -}); - -export const layer = Layer.effect(DesktopCloudAuth, make); diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts deleted file mode 100644 index 3257edca885..00000000000 --- a/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -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 Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts"; - -const textDecoder = new TextDecoder(); -const textEncoder = new TextEncoder(); - -function makeSafeStorageLayer(input: { readonly available: boolean }) { - return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { - isEncryptionAvailable: Effect.succeed(input.available), - encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)), - decryptString: (value) => { - const decoded = textDecoder.decode(value); - if (!decoded.startsWith("enc:")) { - return Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageDecryptError({ - cause: new Error("invalid encrypted token"), - }), - ); - } - return Effect.succeed(decoded.slice("enc:".length)); - }, - } satisfies ElectronSafeStorage.ElectronSafeStorageShape); -} - -function makeLayer(baseDir: string, input?: { readonly encryptionAvailable?: boolean }) { - const environmentLayer = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: baseDir, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), - ), - ); - - return DesktopCloudAuthTokenStore.layer.pipe( - Layer.provideMerge(environmentLayer), - Layer.provideMerge(makeSafeStorageLayer({ available: input?.encryptionAvailable ?? true })), - Layer.provideMerge(NodeServices.layer), - ); -} - -const withTokenStore = ( - effect: Effect.Effect, - input?: { readonly encryptionAvailable?: boolean }, -) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-cloud-auth-token-test-", - }); - return yield* effect.pipe(Effect.provide(makeLayer(baseDir, input))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); - -describe("DesktopCloudAuthTokenStore", () => { - it.effect("persists, reads, and clears the encrypted Clerk client JWT", () => - withTokenStore( - Effect.gen(function* () { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - - assert.isTrue(yield* tokenStore.set("__client=test.jwt")); - assert.deepStrictEqual(yield* tokenStore.get, Option.some("__client=test.jwt")); - - yield* tokenStore.clear; - assert.deepStrictEqual(yield* tokenStore.get, Option.none()); - }), - ), - ); - - it.effect("does not persist a token when Electron safe storage is unavailable", () => - withTokenStore( - Effect.gen(function* () { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - - assert.isFalse(yield* tokenStore.set("__client=test.jwt")); - assert.deepStrictEqual(yield* tokenStore.get, Option.none()); - }), - { encryptionAvailable: false }, - ), - ); -}); diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts deleted file mode 100644 index 652072c1f5d..00000000000 --- a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { fromLenientJson } from "@t3tools/shared/schemaJson"; -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; - -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -interface CloudAuthTokenDocument { - readonly version: number; - readonly encryptedClientJwt: string; -} - -const CloudAuthTokenDocumentSchema = Schema.Struct({ - version: Schema.Number, - encryptedClientJwt: Schema.String, -}); - -const CloudAuthTokenDocumentJson = fromLenientJson(CloudAuthTokenDocumentSchema); -const decodeCloudAuthTokenDocumentJson = Schema.decodeEffect(CloudAuthTokenDocumentJson); -const encodeCloudAuthTokenDocumentJson = Schema.encodeEffect(CloudAuthTokenDocumentJson); - -export class DesktopCloudAuthTokenStoreWriteError extends Data.TaggedError( - "DesktopCloudAuthTokenStoreWriteError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop cloud auth token: ${this.cause.message}`; - } -} - -export class DesktopCloudAuthTokenStoreDecodeError extends Data.TaggedError( - "DesktopCloudAuthTokenStoreDecodeError", -)<{ - readonly cause: Encoding.EncodingError; -}> { - override get message() { - return "Failed to decode desktop cloud auth token."; - } -} - -export interface DesktopCloudAuthTokenStoreShape { - readonly get: Effect.Effect< - Option.Option, - | DesktopCloudAuthTokenStoreDecodeError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError - >; - readonly set: ( - token: string, - ) => Effect.Effect< - boolean, - | DesktopCloudAuthTokenStoreWriteError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError - >; - readonly clear: Effect.Effect; -} - -export class DesktopCloudAuthTokenStore extends Context.Service< - DesktopCloudAuthTokenStore, - DesktopCloudAuthTokenStoreShape ->()("@t3tools/desktop/app/DesktopCloudAuthTokenStore") {} - -function decodeSecretBytes( - encoded: string, -): Effect.Effect { - return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( - Effect.mapError((cause) => new DesktopCloudAuthTokenStoreDecodeError({ cause })), - ); -} - -const readDocument = ( - fileSystem: FileSystem.FileSystem, - tokenPath: string, -): Effect.Effect> => - fileSystem.readFileString(tokenPath).pipe( - Effect.option, - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed(Option.none()), - onSome: (raw) => decodeCloudAuthTokenDocumentJson(raw).pipe(Effect.option), - }), - ), - ); - -const writeDocument = Effect.fn("desktop.cloudAuthTokenStore.writeDocument")(function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly tokenPath: string; - readonly document: CloudAuthTokenDocument; - readonly suffix: string; -}): Effect.fn.Return { - const directory = input.path.dirname(input.tokenPath); - const tempPath = `${input.tokenPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeCloudAuthTokenDocumentJson(input.document); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.tokenPath); -}); - -export const layer = Layer.effect( - DesktopCloudAuthTokenStore, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const crypto = yield* Crypto.Crypto; - const tokenPath = path.join(environment.stateDir, "cloud-auth-token.json"); - - return DesktopCloudAuthTokenStore.of({ - get: Effect.gen(function* () { - const document = yield* readDocument(fileSystem, tokenPath); - if (Option.isNone(document) || !(yield* safeStorage.isEncryptionAvailable)) { - return Option.none(); - } - - const secretBytes = yield* decodeSecretBytes(document.value.encryptedClientJwt); - return Option.some(yield* safeStorage.decryptString(secretBytes)); - }).pipe(Effect.withSpan("desktop.cloudAuthTokenStore.get")), - set: Effect.fn("desktop.cloudAuthTokenStore.set")(function* (token) { - if (!(yield* safeStorage.isEncryptionAvailable)) { - return false; - } - - const encryptedClientJwt = Encoding.encodeBase64(yield* safeStorage.encryptString(token)); - const suffix = (yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause })), - )).replace(/-/g, ""); - yield* writeDocument({ - fileSystem, - path, - tokenPath, - document: { version: 1, encryptedClientJwt }, - suffix, - }).pipe(Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause }))); - return true; - }), - clear: fileSystem.remove(tokenPath, { force: true }).pipe( - Effect.catch(() => Effect.void), - Effect.withSpan("desktop.cloudAuthTokenStore.clear"), - ), - }); - }), -); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts new file mode 100644 index 00000000000..7c7818994f6 --- /dev/null +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -0,0 +1,415 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +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 PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); +const decodeConnectionCatalog = Schema.decodeEffect( + Schema.fromJsonString(ConnectionCatalogDocument), +); +function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref | null = null) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: Effect.succeed(available), + encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)), + decryptString: (value) => { + return Effect.gen(function* () { + const decoded = textDecoder.decode(value); + if ( + !decoded.startsWith("encrypted:") || + (failDecrypt !== null && (yield* Ref.get(failDecrypt))) + ) { + return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("invalid encrypted catalog"), + }); + } + return decoded.slice("encrypted:".length); + }); + }, + } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); +} + +function makeLayer( + baseDir: string, + encryptionAvailable = true, + failDecrypt: Ref.Ref | null = null, + fileSystemLayer: Layer.Layer = NodeServices.layer, +) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + const safeStorageLayer = makeSafeStorageLayer(encryptionAvailable, failDecrypt); + const dependencies = Layer.mergeAll( + environmentLayer, + safeStorageLayer, + NodeServices.layer, + fileSystemLayer, + ); + const savedEnvironmentsLayer = DesktopSavedEnvironments.layer.pipe( + Layer.provideMerge(dependencies), + ); + + return DesktopConnectionCatalogStore.layer.pipe( + Layer.provideMerge(savedEnvironmentsLayer), + Layer.provideMerge(dependencies), + ); +} + +const withStore = ( + effect: Effect.Effect, + encryptionAvailable = true, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir, encryptionAvailable))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopConnectionCatalogStore", () => { + it.effect("persists, reads, and clears an encrypted connection catalog", () => + withStore( + Effect.gen(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalog = '{"schemaVersion":1,"targets":[]}'; + + assert.isTrue(yield* store.set(catalog)); + assert.deepStrictEqual(yield* store.get, Option.some(catalog)); + + yield* store.clear; + assert.deepStrictEqual(yield* store.get, Option.none()); + }), + ), + ); + + it.effect("does not persist when secure storage is unavailable", () => + withStore( + Effect.gen(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + assert.isFalse(yield* store.set("{}")); + assert.deepStrictEqual(yield* store.get, Option.none()); + }), + false, + ), + ); + + it.effect("migrates legacy relay, SSH, bearer profile, and credential data", () => + withStore( + Effect.gen(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + const records: readonly PersistedSavedEnvironmentRecord[] = [ + { + environmentId: EnvironmentId.make("relay-environment"), + label: "Relay", + httpBaseUrl: "https://relay.example.com/", + wsBaseUrl: "wss://relay.example.com/", + createdAt: "2026-06-01T00:00:00.000Z", + lastConnectedAt: null, + relayManaged: { relayUrl: "https://relay-control.example.com/" }, + }, + { + environmentId: EnvironmentId.make("ssh-environment"), + label: "SSH", + httpBaseUrl: "http://127.0.0.1:41773/", + wsBaseUrl: "ws://127.0.0.1:41773/", + createdAt: "2026-06-02T00:00:00.000Z", + lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }, + { + environmentId: EnvironmentId.make("bearer-environment"), + label: "Bearer", + httpBaseUrl: "https://bearer.example.com/", + wsBaseUrl: "wss://bearer.example.com/", + createdAt: "2026-06-03T00:00:00.000Z", + lastConnectedAt: null, + }, + ]; + yield* savedEnvironments.setRegistry(records); + assert.isTrue( + yield* savedEnvironments.setSecret({ + environmentId: EnvironmentId.make("bearer-environment"), + secret: "legacy-token", + }), + ); + + const migrated = yield* store.get; + assert.isTrue(Option.isSome(migrated)); + if (Option.isNone(migrated)) { + return; + } + const catalog = yield* decodeConnectionCatalog(migrated.value); + + assert.deepInclude(catalog.targets[0], { + _tag: "RelayConnectionTarget", + environmentId: EnvironmentId.make("relay-environment"), + label: "Relay", + }); + assert.deepInclude(catalog.targets[1], { + _tag: "SshConnectionTarget", + environmentId: EnvironmentId.make("ssh-environment"), + label: "SSH", + connectionId: "ssh:ssh-environment", + }); + assert.deepInclude(catalog.targets[2], { + _tag: "BearerConnectionTarget", + environmentId: EnvironmentId.make("bearer-environment"), + label: "Bearer", + connectionId: "bearer:bearer-environment", + }); + assert.deepInclude(catalog.profiles[0], { + _tag: "SshConnectionProfile", + connectionId: "ssh:ssh-environment", + environmentId: EnvironmentId.make("ssh-environment"), + label: "SSH", + target: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }); + assert.deepInclude(catalog.profiles[1], { + _tag: "BearerConnectionProfile", + connectionId: "bearer:bearer-environment", + environmentId: EnvironmentId.make("bearer-environment"), + label: "Bearer", + httpBaseUrl: "https://bearer.example.com/", + wsBaseUrl: "wss://bearer.example.com/", + }); + assert.equal(catalog.credentials.length, 1); + assert.equal(catalog.credentials[0]?.connectionId, "bearer:bearer-environment"); + assert.equal(catalog.credentials[0]?.credential._tag, "BearerConnectionCredential"); + if (catalog.credentials[0]?.credential._tag === "BearerConnectionCredential") { + assert.equal(catalog.credentials[0].credential.token, "legacy-token"); + } + + yield* savedEnvironments.setRegistry([]); + assert.deepEqual(yield* store.get, migrated); + }), + ), + ); + + it.effect("surfaces malformed catalog documents without deleting them", () => + withStore( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalogPath = `${environment.stateDir}/connection-catalog.json`; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(catalogPath, "{not-json"); + + const error = yield* store.get.pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError, + ); + assert.equal(error.catalogPath, catalogPath); + assert.exists(error.cause); + assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); + }), + ), + ); + + it.effect("surfaces catalog filesystem failures instead of treating them as missing", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.fail(permissionError), + }), + ); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(makeLayer(baseDir, true, null, fileSystemLayer)), + ); + + const error = yield* store.get.pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, + ); + assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.strictEqual(error.cause, permissionError); + assert.equal( + error.message, + `Failed to read the desktop connection catalog at ${baseDir}/userdata/connection-catalog.json.`, + ); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports the failed catalog write operation and path", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: `${baseDir}/userdata`, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + makeDirectory: () => Effect.fail(permissionError), + }), + ); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(makeLayer(baseDir, true, null, fileSystemLayer)), + ); + + const error = yield* store.set("{}").pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, + ); + assert.equal(error.operation, "create-directory"); + assert.equal(error.path, `${baseDir}/userdata`); + assert.strictEqual(error.cause, permissionError); + assert.equal( + error.message, + `Desktop connection catalog write failed during create-directory at ${baseDir}/userdata.`, + ); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports the legacy migration stage", () => + withStore( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); + + const error = yield* store.get.pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError, + ); + assert.equal(error.operation, "read-legacy-registry"); + assert.equal(error.catalogPath, `${environment.stateDir}/connection-catalog.json`); + assert.instanceOf( + error.cause, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, + ); + const registryError = + error.cause as DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError; + assert.exists(registryError.cause); + assert.equal( + error.message, + `Legacy desktop saved-environment migration failed during read-legacy-registry into ${environment.stateDir}/connection-catalog.json.`, + ); + assert.notEqual(error.message, registryError.message); + }), + ), + ); + + it.effect("reports invalid encrypted catalog data without exposing it", () => + withStore( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalogPath = `${environment.stateDir}/connection-catalog.json`; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n'); + + const error = yield* store.get.pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDecodeError, + ); + assert.equal(error.resource, "encryptedCatalog"); + assert.equal(error.catalogPath, catalogPath); + assert.exists(error.cause); + assert.equal( + error.message, + `Failed to decode encryptedCatalog for the desktop connection catalog at ${catalogPath}.`, + ); + assert.notInclude(error.message, "%%%"); + }), + ), + ); + + it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const failDecrypt = yield* Ref.make(false); + const layer = makeLayer(baseDir, true, failDecrypt); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(layer), + ); + + assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); + yield* Ref.set(failDecrypt, true); + const error = yield* store.get.pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, + ); + assert.equal(error.operation, "decrypt-catalog"); + assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError); + const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError; + assert.instanceOf(decryptError.cause, Error); + assert.equal(decryptError.cause.message, "invalid encrypted catalog"); + assert.equal( + error.message, + `Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`, + ); + assert.notEqual(error.message, decryptError.message); + yield* Ref.set(failDecrypt, false); + assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts new file mode 100644 index 00000000000..5ec2edb595f --- /dev/null +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -0,0 +1,516 @@ +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionProfile, + SshConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { + ConnectionCatalogDocument as RuntimeConnectionCatalogDocument, + type ConnectionCatalogDocument as RuntimeConnectionCatalogDocumentType, +} from "@t3tools/client-runtime/platform"; +import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const EncryptedConnectionCatalogDocument = Schema.Struct({ + version: Schema.Literal(1), + encryptedCatalog: Schema.String, +}); +type EncryptedConnectionCatalogDocument = typeof EncryptedConnectionCatalogDocument.Type; + +const EncryptedConnectionCatalogDocumentJson = fromLenientJson(EncryptedConnectionCatalogDocument); +const decodeEncryptedConnectionCatalogDocumentJson = Schema.decodeEffect( + EncryptedConnectionCatalogDocumentJson, +); +const encodeEncryptedConnectionCatalogDocumentJson = Schema.encodeEffect( + EncryptedConnectionCatalogDocumentJson, +); +const RuntimeConnectionCatalogDocumentJson = Schema.fromJsonString( + RuntimeConnectionCatalogDocument, +); +const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect( + RuntimeConnectionCatalogDocumentJson, +); + +const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ + "create-temporary-file-name", + "encode-document", + "create-directory", + "write-temporary-file", + "replace-catalog-file", +]); + +const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ + "read-legacy-registry", + "read-legacy-secret", + "encode-catalog", + "persist-catalog", +]); + +const DesktopConnectionCatalogStoreProtectionOperation = Schema.Literals([ + "check-encryption-availability", + "encrypt-catalog", + "decrypt-catalog", +]); + +export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreWriteError", + { + operation: DesktopConnectionCatalogStoreWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop connection catalog write failed during ${this.operation} at ${this.path}.`; + } +} + +export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreDecodeError", + { + resource: Schema.Literal("encryptedCatalog"), + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode ${this.resource} for the desktop connection catalog at ${this.catalogPath}.`; + } +} + +export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreReadError", + { + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the desktop connection catalog at ${this.catalogPath}.`; + } +} + +export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreDocumentDecodeError", + { + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode the desktop connection catalog document at ${this.catalogPath}.`; + } +} + +export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreMigrationError", + { + operation: DesktopConnectionCatalogStoreMigrationOperation, + catalogPath: Schema.String, + environmentId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const environment = + this.environmentId === undefined ? "" : ` for environment ${this.environmentId}`; + return `Legacy desktop saved-environment migration failed during ${this.operation}${environment} into ${this.catalogPath}.`; + } +} + +export class DesktopConnectionCatalogStoreProtectionError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreProtectionError", + { + operation: DesktopConnectionCatalogStoreProtectionOperation, + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop connection catalog protection failed during ${this.operation} at ${this.catalogPath}.`; + } +} + +export class DesktopConnectionCatalogStore extends Context.Service< + DesktopConnectionCatalogStore, + { + readonly get: Effect.Effect< + Option.Option, + | DesktopConnectionCatalogStoreReadError + | DesktopConnectionCatalogStoreDocumentDecodeError + | DesktopConnectionCatalogStoreDecodeError + | DesktopConnectionCatalogStoreMigrationError + | DesktopConnectionCatalogStoreProtectionError + >; + readonly set: ( + catalog: string, + ) => Effect.Effect< + boolean, + DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError + >; + readonly clear: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} + +function decodeSecretBytes( + catalogPath: string, + encoded: string, +): Effect.Effect { + return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreDecodeError({ + resource: "encryptedCatalog", + catalogPath, + cause, + }), + ), + ); +} + +const readDocument = ( + fileSystem: FileSystem.FileSystem, + catalogPath: string, +): Effect.Effect< + Option.Option, + DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError +> => + fileSystem.readFileString(catalogPath).pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new DesktopConnectionCatalogStoreReadError({ + catalogPath, + cause: error, + }), + ), + ), + Effect.flatMap((raw) => + raw === null + ? Effect.succeed(Option.none()) + : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe( + Effect.map(Option.some), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreDocumentDecodeError({ + catalogPath, + cause, + }), + ), + ), + ), + ); + +const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly catalogPath: string; + readonly document: EncryptedConnectionCatalogDocument; + readonly suffix: string; +}): Effect.fn.Return { + const directory = input.path.dirname(input.catalogPath); + const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`; + const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "encode-document", + path: input.catalogPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* Effect.gen(function* () { + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.catalogPath).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "replace-catalog-file", + path: input.catalogPath, + cause, + }), + ), + ); + }).pipe( + Effect.ensuring( + input.fileSystem.remove(tempPath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove a temporary connection catalog file.", { + tempPath, + error, + }), + ), + ), + ), + ); +}); + +function connectionId(prefix: "bearer" | "ssh", environmentId: string): string { + return `${prefix}:${environmentId}`; +} + +const migrateSavedEnvironmentRecords = Effect.fn( + "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords", +)(function* ( + records: readonly PersistedSavedEnvironmentRecord[], + savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironments["Service"], + catalogPath: string, +): Effect.fn.Return< + RuntimeConnectionCatalogDocumentType, + DesktopConnectionCatalogStoreMigrationError +> { + const targets: Array = []; + const profiles: Array = []; + const credentials: Array = []; + + for (const record of records) { + if (record.relayManaged !== undefined) { + targets.push( + new RelayConnectionTarget({ + environmentId: record.environmentId, + label: record.label, + }), + ); + continue; + } + + if (record.desktopSsh !== undefined) { + const id = connectionId("ssh", record.environmentId); + targets.push( + new SshConnectionTarget({ + environmentId: record.environmentId, + label: record.label, + connectionId: id, + }), + ); + profiles.push( + new SshConnectionProfile({ + connectionId: id, + environmentId: record.environmentId, + label: record.label, + target: record.desktopSsh, + }), + ); + continue; + } + + const id = connectionId("bearer", record.environmentId); + targets.push( + new BearerConnectionTarget({ + environmentId: record.environmentId, + label: record.label, + connectionId: id, + }), + ); + profiles.push( + new BearerConnectionProfile({ + connectionId: id, + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + }), + ); + const token = yield* savedEnvironments.getSecret(record.environmentId).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "read-legacy-secret", + catalogPath, + environmentId: record.environmentId, + cause, + }), + ), + ); + if (Option.isSome(token)) { + credentials.push({ + connectionId: id, + credential: new BearerConnectionCredential({ token: token.value }), + }); + } + } + + return { + schemaVersion: 1, + targets, + profiles, + credentials, + remoteDpopTokens: [], + }; +}); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const crypto = yield* Crypto.Crypto; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); + const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "check-encryption-availability", + catalogPath, + cause, + }), + ), + ); + + const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( + catalog: string, + ) { + const encryptedCatalog = Encoding.encodeBase64( + yield* safeStorage.encryptString(catalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "encrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ); + const suffix = (yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "create-temporary-file-name", + path: catalogPath, + cause, + }), + ), + )).replace(/-/g, ""); + yield* writeDocument({ + fileSystem, + path, + catalogPath, + document: { version: 1, encryptedCatalog }, + suffix, + }); + }); + + const migrateLegacyCatalog = Effect.gen(function* () { + if (!(yield* encryptionAvailable)) { + return Option.none(); + } + const records = yield* savedEnvironments.getRegistry.pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "read-legacy-registry", + catalogPath, + cause, + }), + ), + ); + if (records.length === 0) { + return Option.none(); + } + const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath); + const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "encode-catalog", + catalogPath, + cause, + }), + ), + ); + yield* writeCatalog(encoded).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "persist-catalog", + catalogPath, + cause, + }), + ), + ); + return Option.some(encoded); + }); + + return DesktopConnectionCatalogStore.of({ + get: Effect.gen(function* () { + const document = yield* readDocument(fileSystem, catalogPath); + if (Option.isNone(document)) { + return yield* migrateLegacyCatalog; + } + if (!(yield* encryptionAvailable)) { + return Option.none(); + } + const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( + Effect.flatMap((encryptedCatalog) => + safeStorage.decryptString(encryptedCatalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "decrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ), + ); + return Option.some(decrypted); + }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), + set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { + if (!(yield* encryptionAvailable)) { + return false; + } + yield* writeCatalog(catalog); + return true; + }), + clear: fileSystem.remove(catalogPath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear the desktop connection catalog.", { + catalogPath, + error, + }), + ), + Effect.withSpan("desktop.connectionCatalogStore.clear"), + ), + }); +}); + +export const layer = Layer.effect(DesktopConnectionCatalogStore, make); diff --git a/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts b/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts new file mode 100644 index 00000000000..ae78080539b --- /dev/null +++ b/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts @@ -0,0 +1,37 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; + +import { DesktopLifecycleRelaunchError } from "./DesktopLifecycle.ts"; +import { DesktopApplicationMenuActionError } from "../window/DesktopApplicationMenu.ts"; + +describe("desktop detached action errors", () => { + it("preserves the complete relaunch failure cause and reason", () => { + const cause = Cause.combine( + Cause.fail(new Error("shutdown failed")), + Cause.die(new Error("relaunch defect")), + ); + const error = new DesktopLifecycleRelaunchError({ + reason: "apply update", + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.reason, "apply update"); + assert.equal(error.message, 'Desktop relaunch failed for reason "apply update".'); + }); + + it("preserves the complete menu action failure cause and action", () => { + const cause = Cause.combine( + Cause.fail(new Error("window unavailable")), + Cause.die(new Error("dispatch defect")), + ); + const error = new DesktopApplicationMenuActionError({ + action: "open-settings", + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.action, "open-settings"); + assert.equal(error.message, 'Desktop menu action "open-settings" failed.'); + }); +}); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 9c3ebbaa949..fa51e470628 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -12,10 +12,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { - type DesktopSettings, - resolveDefaultDesktopSettings, -} from "../settings/DesktopAppSettings.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; @@ -31,55 +28,53 @@ export interface MakeDesktopEnvironmentInput { readonly runningUnderArm64Translation: boolean; } -export interface DesktopEnvironmentShape { - readonly path: Path.Path; - readonly dirname: string; - readonly platform: NodeJS.Platform; - readonly processArch: string; - readonly isPackaged: boolean; - readonly isDevelopment: boolean; - readonly appVersion: string; - readonly appPath: string; - readonly resourcesPath: string; - readonly homeDirectory: string; - readonly appDataDirectory: string; - readonly baseDir: string; - readonly stateDir: string; - readonly desktopSettingsPath: string; - readonly clientSettingsPath: string; - readonly savedEnvironmentRegistryPath: string; - readonly serverSettingsPath: string; - readonly logDir: string; - readonly browserArtifactsDir: string; - readonly rootDir: string; - readonly appRoot: string; - readonly backendEntryPath: string; - readonly backendCwd: string; - readonly preloadPath: string; - readonly appUpdateYmlPath: string; - readonly devServerUrl: Option.Option; - readonly devRemoteT3ServerEntryPath: Option.Option; - readonly configuredBackendPort: Option.Option; - readonly commitHashOverride: Option.Option; - readonly otlpTracesUrl: Option.Option; - readonly otlpExportIntervalMs: number; - readonly branding: DesktopAppBranding; - readonly displayName: string; - readonly appUserModelId: string; - readonly linuxDesktopEntryName: string; - readonly linuxWmClass: string; - readonly userDataDirName: string; - readonly legacyUserDataDirName: string; - readonly defaultDesktopSettings: DesktopSettings; - readonly runtimeInfo: DesktopRuntimeInfo; - readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; - readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; - readonly developmentDockIconPath: string; -} - export class DesktopEnvironment extends Context.Service< DesktopEnvironment, - DesktopEnvironmentShape + { + readonly path: Path.Path; + readonly dirname: string; + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly isPackaged: boolean; + readonly isDevelopment: boolean; + readonly appVersion: string; + readonly appPath: string; + readonly resourcesPath: string; + readonly homeDirectory: string; + readonly appDataDirectory: string; + readonly baseDir: string; + readonly stateDir: string; + readonly desktopSettingsPath: string; + readonly clientSettingsPath: string; + readonly savedEnvironmentRegistryPath: string; + readonly serverSettingsPath: string; + readonly logDir: string; + readonly browserArtifactsDir: string; + readonly rootDir: string; + readonly appRoot: string; + readonly backendEntryPath: string; + readonly backendCwd: string; + readonly preloadPath: string; + readonly appUpdateYmlPath: string; + readonly devServerUrl: Option.Option; + readonly devRemoteT3ServerEntryPath: Option.Option; + readonly configuredBackendPort: Option.Option; + readonly commitHashOverride: Option.Option; + readonly otlpTracesUrl: Option.Option; + readonly otlpExportIntervalMs: number; + readonly branding: DesktopAppBranding; + readonly displayName: string; + readonly appUserModelId: string; + readonly linuxDesktopEntryName: string; + readonly linuxWmClass: string; + readonly userDataDirName: string; + readonly legacyUserDataDirName: string; + readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; + readonly runtimeInfo: DesktopRuntimeInfo; + readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; + readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; + readonly developmentDockIconPath: string; + } >()("@t3tools/desktop/app/DesktopEnvironment") {} const APP_BASE_NAME = "T3 Code"; @@ -137,9 +132,9 @@ function resolveDesktopRuntimeInfo(input: { }; } -const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( +const make = Effect.fn("desktop.environment.make")(function* ( input: MakeDesktopEnvironmentInput, -): Effect.fn.Return { +): Effect.fn.Return { const path = yield* Path.Path; const config = yield* DesktopConfig.DesktopConfig; const homeDirectory = input.homeDirectory; @@ -209,7 +204,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", userDataDirName, legacyUserDataDirName, - defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion), + defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ platform: input.platform, processArch: input.processArch, @@ -251,6 +246,6 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( }); export const layer = (input: MakeDesktopEnvironmentInput) => - Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)).pipe( + Layer.effect(DesktopEnvironment, make(input)).pipe( Layer.provide(input.platform === "win32" ? NodePath.layerWin32 : NodePath.layerPosix), ); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index a7957ffca19..c5264332b66 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,75 +1,55 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as Deferred from "effect/Deferred"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopObservability from "./DesktopObservability.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; -export interface DesktopShutdownShape { - readonly request: Effect.Effect; - readonly awaitRequest: Effect.Effect; - readonly markComplete: Effect.Effect; - readonly awaitComplete: Effect.Effect; - readonly isComplete: Effect.Effect; +export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass()( + "DesktopLifecycleRelaunchError", + { + reason: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop relaunch failed for reason "${this.reason}".`; + } } -export class DesktopShutdown extends Context.Service()( - "@t3tools/desktop/app/DesktopLifecycle/DesktopShutdown", -) {} - -const makeShutdown = Effect.gen(function* () { - const requested = yield* Deferred.make(); - const completed = yield* Deferred.make(); - const completedRef = yield* Ref.make(false); - - return DesktopShutdown.of({ - request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), - awaitRequest: Deferred.await(requested), - markComplete: Ref.set(completedRef, true).pipe( - Effect.andThen(Deferred.succeed(completed, undefined)), - Effect.asVoid, - ), - awaitComplete: Deferred.await(completed), - isComplete: Ref.get(completedRef), - }); -}); - -export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown); - export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment - | DesktopShutdown + | DesktopShutdown.DesktopShutdown | DesktopState.DesktopState | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp | ElectronTheme.ElectronTheme; -export interface DesktopLifecycleShape { - readonly relaunch: ( - reason: string, - ) => Effect.Effect; - readonly register: Effect.Effect; -} - /** * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme */ -export class DesktopLifecycle extends Context.Service()( - "@t3tools/desktop/app/DesktopLifecycle", -) {} +export class DesktopLifecycle extends Context.Service< + DesktopLifecycle, + { + readonly relaunch: ( + reason: string, + ) => Effect.Effect; + readonly register: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopLifecycle") {} const { logInfo: logLifecycleInfo, logError: logLifecycleError } = - DesktopObservability.makeComponentLogger("desktop-lifecycle"); + makeComponentLogger("desktop-lifecycle"); function addScopedListener>( target: unknown, @@ -93,8 +73,8 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { - const shutdown = yield* DesktopShutdown; + function* (): Effect.fn.Return { + const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* shutdown.request; yield* shutdown.awaitComplete; }, @@ -154,83 +134,81 @@ function quitFromSignal( ); } -export const layer = Layer.succeed( - DesktopLifecycle, - DesktopLifecycle.of({ - relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { - const electronApp = yield* ElectronApp.ElectronApp; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const state = yield* DesktopState.DesktopState; - yield* logLifecycleInfo("desktop relaunch requested", { reason }); - yield* Effect.gen(function* () { - yield* Effect.yieldNow; - yield* Ref.set(state.quitting, true); - yield* requestDesktopShutdownAndWait(); - if (environment.isDevelopment) { - yield* electronApp.exit(75); - return; - } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); - yield* electronApp.exit(0); - }).pipe( - Effect.catchCause((cause) => - logLifecycleError("desktop relaunch failed", { - cause: Cause.pretty(cause), - }), - ), - Effect.forkDetach, - Effect.asVoid, - ); - }), - register: Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const electronApp = yield* ElectronApp.ElectronApp; - const electronTheme = yield* ElectronTheme.ElectronTheme; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const context = yield* Effect.context(); - const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; - yield* electronTheme.onUpdated(() => { - void runEffect( - desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), - ); - }); - yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed, - () => { - quitAllowed = true; - }, - ); +export const make = DesktopLifecycle.of({ + relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const state = yield* DesktopState.DesktopState; + yield* logLifecycleInfo("desktop relaunch requested", { reason }); + yield* Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Ref.set(state.quitting, true); + yield* requestDesktopShutdownAndWait(); + if (environment.isDevelopment) { + yield* electronApp.exit(75); + return; + } + yield* electronApp.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), }); - yield* electronApp.on("activate", () => { - void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); + yield* electronApp.exit(0); + }).pipe( + Effect.catchCause((cause) => { + const error = new DesktopLifecycleRelaunchError({ reason, cause }); + return logLifecycleError(error.message, { error }); + }), + Effect.forkDetach, + Effect.asVoid, + ); + }), + register: Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = Effect.runPromiseWith(context); + let quitAllowed = false; + yield* electronTheme.onUpdated(() => { + void runEffect( + desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), + ); + }); + yield* electronApp.on("before-quit", (event: Electron.Event) => { + handleBeforeQuit( + event, + runEffect, + () => quitAllowed, + () => { + quitAllowed = true; + }, + ); + }); + yield* electronApp.on("activate", () => { + void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); + }); + yield* electronApp.on("window-all-closed", () => { + void runEffect( + Effect.gen(function* () { + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + yield* app.quit; + } + }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), + ); + }); + + if (environment.platform !== "win32") { + yield* addScopedListener(process, "SIGINT", () => { + quitFromSignal("SIGINT", runEffect); }); - yield* electronApp.on("window-all-closed", () => { - void runEffect( - Effect.gen(function* () { - const app = yield* ElectronApp.ElectronApp; - const state = yield* DesktopState.DesktopState; - if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { - yield* app.quit; - } - }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), - ); + yield* addScopedListener(process, "SIGTERM", () => { + quitFromSignal("SIGTERM", runEffect); }); + } + }).pipe(Effect.withSpan("desktop.lifecycle.register")), +}); - if (environment.platform !== "win32") { - yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); - }); - yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); - }); - } - }).pipe(Effect.withSpan("desktop.lifecycle.register")), - }), -); +export const layer = Layer.succeed(DesktopLifecycle, make); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 2349fe52dc3..21dd27ba28d 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,52 +1,20 @@ import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -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 PlatformError from "effect/PlatformError"; import * as References from "effect/References"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; import * as Tracer from "effect/Tracer"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; -const DESKTOP_LOG_FILE_MAX_FILES = 10; -const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; -export interface RotatingLogFileWriter { - readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; - readonly writeText: (chunk: string) => Effect.Effect; -} - -export interface DesktopBackendOutputLogShape { - readonly writeSessionBoundary: (input: { - readonly phase: "START" | "END"; - readonly details: string; - }) => Effect.Effect; - readonly writeOutputChunk: ( - streamName: "stdout" | "stderr", - chunk: Uint8Array, - ) => Effect.Effect; -} - -export class DesktopBackendOutputLog extends Context.Service< - DesktopBackendOutputLog, - DesktopBackendOutputLogShape ->()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLog") {} - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); +export { DesktopBackendOutputLog } from "./DesktopBackendOutputLog.ts"; export type DesktopLogAnnotations = Record; @@ -82,165 +50,7 @@ export function makeComponentLogger(component: string): DesktopComponentLogger { }; } -class DesktopLogFileWriterConfigurationError extends Data.TaggedError( - "DesktopLogFileWriterConfigurationError", -)<{ - readonly option: "maxBytes" | "maxFiles"; - readonly value: number; -}> { - override get message() { - return `${this.option} must be >= 1 (received ${this.value})`; - } -} - -type DesktopLogFileWriterError = - | DesktopLogFileWriterConfigurationError - | PlatformError.PlatformError; - -const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); - -const DesktopBackendChildLogRecord = Schema.Struct({ - message: Schema.String, - level: Schema.Literals(["INFO", "ERROR"]), - timestamp: Schema.String, - annotations: Schema.Record(Schema.String, Schema.Unknown), - spans: Schema.Record(Schema.String, Schema.Unknown), - fiberId: Schema.String, -}); - -const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( - Schema.fromJsonString(DesktopBackendChildLogRecord), -); - -const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { - writeSessionBoundary: () => Effect.void, - writeOutputChunk: () => Effect.void, -}; - -const currentDesktopRunId = Effect.gen(function* () { - const annotations = yield* References.CurrentLogAnnotations; - const runId = annotations.runId; - return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; -}); - -const refreshFileSize = ( - fileSystem: FileSystem.FileSystem, - filePath: string, -): Effect.Effect => - fileSystem.stat(filePath).pipe( - Effect.map((stat) => Number(stat.size)), - Effect.orElseSucceed(() => 0), - ); - -const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { - readonly filePath: string; - readonly maxBytes?: number; - readonly maxFiles?: number; -}): Effect.fn.Return< - RotatingLogFileWriter, - DesktopLogFileWriterError, - FileSystem.FileSystem | Path.Path -> { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; - const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; - const directory = path.dirname(input.filePath); - const baseName = path.basename(input.filePath); - - if (maxBytes < 1) { - return yield* new DesktopLogFileWriterConfigurationError({ - option: "maxBytes", - value: maxBytes, - }); - } - if (maxFiles < 1) { - return yield* new DesktopLogFileWriterConfigurationError({ - option: "maxFiles", - value: maxFiles, - }); - } - - yield* fileSystem.makeDirectory(directory, { recursive: true }); - - const withSuffix = (index: number) => `${input.filePath}.${index}`; - const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); - const mutex = yield* Semaphore.make(1); - - const pruneOverflowBackups = Effect.gen(function* () { - const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); - for (const entry of entries) { - if (!entry.startsWith(`${baseName}.`)) continue; - const suffix = Number(entry.slice(baseName.length + 1)); - if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; - yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); - } - }); - - const rotate = Effect.gen(function* () { - yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); - for (let index = maxFiles - 1; index >= 1; index -= 1) { - const source = withSuffix(index); - const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); - if (sourceExists) { - yield* fileSystem.rename(source, withSuffix(index + 1)); - } - } - const currentExists = yield* fileSystem - .exists(input.filePath) - .pipe(Effect.orElseSucceed(() => false)); - if (currentExists) { - yield* fileSystem.rename(input.filePath, withSuffix(1)); - } - yield* Ref.set(currentSize, 0); - }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), - ); - - const writeBytes = (chunk: Uint8Array): Effect.Effect => { - if (chunk.byteLength === 0) return Effect.void; - - return mutex.withPermits(1)( - Effect.gen(function* () { - const beforeSize = yield* Ref.get(currentSize); - if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { - yield* rotate; - } - - yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); - const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; - yield* Ref.set(currentSize, afterSize); - - if (afterSize > maxBytes) { - yield* rotate; - } - }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), - ), - ); - }; - - yield* pruneOverflowBackups; - - return { - writeBytes, - writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), - } satisfies RotatingLogFileWriter; -}); - -const readPersistedOtlpTracesUrl: Effect.Effect< - Option.Option, - never, - FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment -> = Effect.gen(function* () { +const readPersistedOtlpTracesUrl = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); @@ -260,90 +70,6 @@ const resolveOtlpTracesUrl = Effect.gen(function* () { return yield* readPersistedOtlpTracesUrl; }); -const writeDevelopmentConsoleOutput = ( - streamName: "stdout" | "stderr", - chunk: Uint8Array, -): Effect.Effect => - Effect.sync(() => { - const output = streamName === "stderr" ? process.stderr : process.stdout; - output.write(chunk); - }).pipe(Effect.ignore); - -const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( - function* ( - logFile: RotatingLogFileWriter, - input: { - readonly message: string; - readonly level: "INFO" | "ERROR"; - readonly annotations: Record; - }, - ): Effect.fn.Return { - return yield* Effect.gen(function* () { - const timestamp = DateTime.formatIso(yield* DateTime.now); - const encoded = yield* encodeDesktopBackendChildLogRecord({ - message: input.message, - level: input.level, - timestamp, - annotations: input.annotations, - spans: {}, - fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, - }); - yield* logFile.writeText(`${encoded}\n`); - }).pipe(Effect.ignore({ log: true })); - }, -); - -const backendOutputLogLayer = Layer.effect( - DesktopBackendOutputLog, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - - const writer = yield* makeRotatingLogFileWriter({ - filePath: environment.path.join(environment.logDir, "server-child.log"), - }).pipe(Effect.option); - - return Option.match(writer, { - onNone: () => DesktopBackendOutputLogNoop, - onSome: (logFile) => - ({ - writeSessionBoundary: Effect.fn( - "desktop.observability.backendOutput.writeSessionBoundary", - )(function* ({ phase, details }) { - const runId = yield* currentDesktopRunId; - yield* writeBackendChildLogRecord(logFile, { - message: `backend child process session ${phase.toLowerCase()}`, - level: "INFO", - annotations: { - component: "desktop-backend-child", - runId, - phase, - details: sanitizeLogValue(details), - }, - }); - }), - writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( - function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - const runId = yield* currentDesktopRunId; - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId, - stream: streamName, - text: textDecoder.decode(chunk), - }, - }); - }, - ), - }) satisfies DesktopBackendOutputLogShape, - }); - }), -); - const desktopLoggerLayer = Layer.mergeAll( Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), Layer.succeed(References.MinimumLogLevel, "Info"), @@ -356,8 +82,8 @@ const tracerLayer = Layer.unwrap( const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); const sink = yield* makeTraceSink({ filePath: tracePath, - maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, - maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, }); const delegate = Option.isNone(otlpTracesUrl) @@ -375,8 +101,8 @@ const tracerLayer = Layer.unwrap( }); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, - maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, - maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, sink, ...(delegate ? { delegate } : {}), @@ -387,7 +113,7 @@ const tracerLayer = Layer.unwrap( ).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); export const layer = Layer.mergeAll( - backendOutputLogLayer, + DesktopBackendOutputLogModule.layer, desktopLoggerLayer, tracerLayer, Layer.succeed(Tracer.MinimumTraceLevel, "Info"), diff --git a/apps/desktop/src/app/DesktopShutdown.ts b/apps/desktop/src/app/DesktopShutdown.ts new file mode 100644 index 00000000000..78b77b565b9 --- /dev/null +++ b/apps/desktop/src/app/DesktopShutdown.ts @@ -0,0 +1,35 @@ +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +export class DesktopShutdown extends Context.Service< + DesktopShutdown, + { + readonly request: Effect.Effect; + readonly awaitRequest: Effect.Effect; + readonly markComplete: Effect.Effect; + readonly awaitComplete: Effect.Effect; + readonly isComplete: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopShutdown") {} + +const make = Effect.gen(function* () { + const requested = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const completedRef = yield* Ref.make(false); + + return DesktopShutdown.of({ + request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), + awaitRequest: Deferred.await(requested), + markComplete: Ref.set(completedRef, true).pipe( + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.asVoid, + ), + awaitComplete: Deferred.await(completed), + isComplete: Ref.get(completedRef), + }); +}); + +export const layer = Layer.effect(DesktopShutdown, make); diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts index f325c99d229..cd2abe91065 100644 --- a/apps/desktop/src/app/DesktopState.ts +++ b/apps/desktop/src/app/DesktopState.ts @@ -3,19 +3,17 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -export interface DesktopStateShape { - readonly backendReady: Ref.Ref; - readonly quitting: Ref.Ref; -} +export class DesktopState extends Context.Service< + DesktopState, + { + readonly backendReady: Ref.Ref; + readonly quitting: Ref.Ref; + } +>()("@t3tools/desktop/app/DesktopState") {} -export class DesktopState extends Context.Service()( - "@t3tools/desktop/app/DesktopState", -) {} +const make = Effect.all({ + backendReady: Ref.make(false), + quitting: Ref.make(false), +}); -export const layer = Layer.effect( - DesktopState, - Effect.all({ - backendReady: Ref.make(false), - quitting: Ref.make(false), - }), -); +export const layer = Layer.effect(DesktopState, make); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 96e56a87c9d..43e77a0c4cb 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -3,6 +3,8 @@ import { assert, describe, it } from "@effect/vitest"; 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 PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -21,6 +23,10 @@ const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), ); +const isDesktopBackendObservabilitySettingsReadError = Schema.is( + DesktopBackendConfiguration.DesktopBackendObservabilitySettingsReadError, +); + const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), backendConfig: Effect.succeed({ @@ -34,7 +40,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.succeed([]), -} satisfies DesktopServerExposure.DesktopServerExposureShape); +} satisfies DesktopServerExposure.DesktopServerExposure["Service"]); function makeEnvironmentLayer( baseDir: string, @@ -166,6 +172,62 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("logs structured context when persisted observability settings cannot be read", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const settingsPath = `${baseDir}/userdata/settings.json`; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: settingsPath, + }); + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + const failingFileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.fail(cause), + }), + ); + + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolve; + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(makeEnvironmentLayer(baseDir)), + Layer.provideMerge(failingFileSystemLayer), + ), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + + assert.isUndefined(config.bootstrap.otlpTracesUrl); + assert.isUndefined(config.bootstrap.otlpMetricsUrl); + + const error = messages + .flatMap((message) => (Array.isArray(message) ? message : [message])) + .find(isDesktopBackendObservabilitySettingsReadError); + assert.isDefined(error); + assert.equal(error.settingsPath, settingsPath); + assert.equal(error.cause, cause); + assert.equal( + error.message, + `Failed to read persisted backend observability settings at ${settingsPath}.`, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("captures backend output in development so child process logs can be persisted", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 5e4e034b5e7..d8bd1a13dcb 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -8,22 +8,32 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; -export interface DesktopBackendConfigurationShape { - readonly resolve: Effect.Effect< - DesktopBackendManager.DesktopBackendStartConfig, - PlatformError.PlatformError - >; +export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( + "DesktopBackendObservabilitySettingsReadError", + { + settingsPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read persisted backend observability settings at ${this.settingsPath}.`; + } } export class DesktopBackendConfiguration extends Context.Service< DesktopBackendConfiguration, - DesktopBackendConfigurationShape + { + readonly resolve: Effect.Effect< + DesktopBackendManager.DesktopBackendStartConfig, + PlatformError.PlatformError + >; + } >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} interface BackendObservabilitySettings { @@ -52,29 +62,34 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); -const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( - "desktop-backend-configuration", -); +const logBackendObservabilitySettingsReadFailure = ( + settingsPath: string, + cause: PlatformError.PlatformError, +) => { + const error = new DesktopBackendObservabilitySettingsReadError({ settingsPath, cause }); + return Effect.logWarning(error).pipe( + Effect.annotateLogs({ + component: "desktop-backend-configuration", + error, + }), + ); +}; -const readPersistedBackendObservabilitySettings: Effect.Effect< - BackendObservabilitySettings, - never, - FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment -> = Effect.gen(function* () { +const readPersistedBackendObservabilitySettings = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const exists = yield* fileSystem - .exists(environment.serverSettingsPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return emptyBackendObservabilitySettings; - } - - const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : logBackendObservabilitySettingsReadFailure(environment.serverSettingsPath, cause).pipe( + Effect.as(Option.none()), + ), + }), + ); if (Option.isNone(raw)) { - yield* logBackendConfigurationWarning( - "failed to read persisted backend observability settings", - ); return emptyBackendObservabilitySettings; } @@ -130,40 +145,39 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv }, ); -export const layer = Layer.effect( - DesktopBackendConfiguration, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const crypto = yield* Crypto.Crypto; - const tokenRef = yield* Ref.make(Option.none()); - const getOrCreateBootstrapToken = Effect.gen(function* () { - const existing = yield* Ref.get(tokenRef); - if (Option.isSome(existing)) { - return existing.value; - } - - const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); - yield* Ref.set(tokenRef, Option.some(token)); - return token; - }); - - return DesktopBackendConfiguration.of({ - resolve: Effect.gen(function* () { - const bootstrapToken = yield* getOrCreateBootstrapToken; - const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - ); - return yield* resolveBackendStartConfig({ - bootstrapToken, - observabilitySettings, - }).pipe( - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), - ); - }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), - }); - }), -); +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const crypto = yield* Crypto.Crypto; + const tokenRef = yield* Ref.make(Option.none()); + const getOrCreateBootstrapToken = Effect.gen(function* () { + const existing = yield* Ref.get(tokenRef); + if (Option.isSome(existing)) { + return existing.value; + } + + const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); + yield* Ref.set(tokenRef, Option.some(token)); + return token; + }); + + return DesktopBackendConfiguration.of({ + resolve: Effect.gen(function* () { + const bootstrapToken = yield* getOrCreateBootstrapToken; + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolveBackendStartConfig({ + bootstrapToken, + observabilitySettings, + }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + ); + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), + }); +}); + +export const layer = Layer.effect(DesktopBackendConfiguration, make); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 6c5109c8714..3c0a513c9b5 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -3,12 +3,15 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -28,6 +31,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), ); +const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", @@ -55,9 +59,9 @@ const configWithObservability: DesktopBackendBootstrapValue = { }; function makeProcess(options?: { - readonly stdout?: Stream.Stream; - readonly stderr?: Stream.Stream; - readonly exitCode?: Effect.Effect; + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: Effect.Effect; readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; }): ChildProcessSpawner.ChildProcessHandle { return ChildProcessSpawner.makeHandle({ @@ -104,9 +108,9 @@ function decodeBootstrap(raw: string) { function makeManagerLayer(input: { readonly spawnerLayer: Layer.Layer; readonly httpClientLayer?: Layer.Layer; - readonly backendOutputLog?: Partial; - readonly desktopState?: DesktopState.DesktopStateShape; - readonly desktopWindow?: Partial; + readonly backendOutputLog?: Partial; + readonly desktopState?: DesktopState.DesktopState["Service"]; + readonly desktopWindow?: Partial; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; }) { return DesktopBackendManager.layer.pipe( @@ -127,7 +131,7 @@ function makeManagerLayer(input: { writeSessionBoundary: () => Effect.void, writeOutputChunk: () => Effect.void, ...input.backendOutputLog, - } satisfies DesktopObservability.DesktopBackendOutputLogShape), + } satisfies DesktopObservability.DesktopBackendOutputLog["Service"]), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected createMain"), ensureMain: Effect.die("unexpected ensureMain"), @@ -138,13 +142,30 @@ function makeManagerLayer(input: { dispatchMenuAction: () => Effect.void, syncAppearance: Effect.void, ...input.desktopWindow, - } satisfies DesktopWindow.DesktopWindowShape), + } satisfies DesktopWindow.DesktopWindow["Service"]), ), ), ); } describe("DesktopBackendManager", () => { + it("preserves the complete restart cause and schedule context", () => { + const cause = Cause.combine( + Cause.fail(new Error("start failed")), + Cause.die(new Error("restart defect")), + ); + const error = new DesktopBackendManager.DesktopBackendRestartError({ + reason: "backend exited with code 1", + delayMs: 500, + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.reason, "backend exited with code 1"); + assert.equal(error.delayMs, 500); + assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay."); + }); + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => Effect.gen(function* () { let spawnedCommand: ChildProcess.Command | undefined; @@ -218,6 +239,243 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("preserves the readiness timeout cause and process context", () => + Effect.gen(function* () { + const requested = yield* Deferred.make(); + const layer = Layer.merge( + TestClock.layer(), + httpClientLayer((request) => + Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), + ), + ); + + yield* Effect.gen(function* () { + const readiness = yield* DesktopBackendManager.waitForHttpReady({ + executablePath: baseConfig.executablePath, + entryPath: baseConfig.entryPath, + cwd: baseConfig.cwd, + httpBaseUrl: baseConfig.httpBaseUrl, + timeout: Duration.millis(50), + }).pipe(Effect.flip, Effect.forkChild); + + const request = yield* Deferred.await(requested); + assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); + + yield* TestClock.adjust(Duration.millis(50)); + const error = yield* Fiber.join(readiness); + + assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); + assert.equal(error.timeoutMs, 50); + assert.isTrue(Cause.isTimeoutError(error.cause)); + assert.equal( + error.message, + "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", + ); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("reports bootstrap encoding failures with stable process context", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + const error = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + bootstrap: { + ...baseConfig.bootstrap, + port: 0, + }, + }).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessBootstrapEncodeError") { + return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.isDefined(error.cause); + assert.equal( + error.message, + "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", + ); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves spawn failures without deriving their message from the cause", () => + Effect.gen(function* () { + const spawnCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: baseConfig.executablePath, + description: "low-level detail that must not become the public message", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(spawnCause)), + ); + const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessSpawnError") { + return assert.fail(`Expected backend spawn error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, spawnCause); + assert.equal( + error.message, + "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", + ); + assert.notInclude(error.message, spawnCause.message); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves exit-status failures without copying their detail into the message", () => + Effect.gen(function* () { + const exitCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "exitCode", + description: "exit-status-secret-sentinel", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Effect.fail(exitCause), + }), + ), + ), + ); + const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessExitStatusError") { + return assert.fail(`Expected backend exit-status error, received ${error._tag}`); + } + assert.equal(error.pid, 123); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, exitCause); + assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); + assert.notInclude(error.message, "exit-status-secret-sentinel"); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("reports output stream failures with process and stream context", () => + Effect.gen(function* () { + const outputCause = PlatformError.systemError({ + _tag: "BadResource", + module: "ChildProcess", + method: "stdout", + description: "output-stream-secret-sentinel", + }); + const reported = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fail(outputCause), + exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + const error = yield* Deferred.await(reported); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + if (error._tag !== "BackendProcessOutputReadError") { + return assert.fail(`Expected output read error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.pid, 123); + assert.equal(error.streamName, "stdout"); + assert.strictEqual(error.cause, outputCause); + assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); + assert.notInclude(error.message, "output-stream-secret-sentinel"); + }), + ); + + it.effect("reports output handler failures separately from stream read failures", () => + Effect.gen(function* () { + const chunk = new TextEncoder().encode("backend output"); + const outputCause = new Error("output-handler-secret-sentinel"); + const reported = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.make(chunk), + exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + onOutput: () => Effect.fail(outputCause), + onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + const error = yield* Deferred.await(reported); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + if (error._tag !== "BackendProcessOutputHandlingError") { + return assert.fail(`Expected output handling error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.pid, 123); + assert.equal(error.streamName, "stdout"); + assert.equal(error.chunkByteLength, chunk.byteLength); + assert.strictEqual(error.cause, outputCause); + assert.equal( + error.message, + `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, + ); + assert.notInclude(error.message, "output-handler-secret-sentinel"); + }), + ); + it.effect("retries HTTP readiness before reporting the backend ready", () => Effect.gen(function* () { const requestUrls: Array = []; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 07693a82707..d92f62d16b7 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,6 +1,5 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -10,14 +9,14 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { DesktopBackendBootstrap, @@ -43,59 +42,145 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; -export interface DesktopBackendStartConfig { +export interface BackendProcessContext { readonly executablePath: string; readonly entryPath: string; readonly cwd: string; + readonly httpBaseUrl: URL; +} + +export interface DesktopBackendStartConfig extends BackendProcessContext { readonly env: Record; readonly bootstrap: DesktopBackendBootstrapValue; - readonly httpBaseUrl: URL; readonly captureOutput: boolean; } interface BackendProcessExit { readonly code: Option.Option; readonly reason: string; - readonly result: Result.Result; } -export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ - readonly url: URL; -}> { - override get message() { - return `Timed out waiting for backend readiness at ${this.url.href}.`; +const backendProcessContextSchema = { + executablePath: Schema.String, + entryPath: Schema.String, + cwd: Schema.String, + httpBaseUrl: Schema.URL, +}; + +export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass()( + "BackendReadinessTimeoutError", + { + ...backendProcessContextSchema, + readinessUrl: Schema.URL, + timeoutMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; } } -class BackendProcessBootstrapEncodeError extends Data.TaggedError( +export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( "BackendProcessBootstrapEncodeError", -)<{ - readonly cause: Schema.SchemaError; -}> { - override get message() { - return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`; + { + ...backendProcessContextSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; } } -class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{ - readonly cause: PlatformError.PlatformError; -}> { - override get message() { - return `Failed to spawn desktop backend process: ${this.cause.message}`; +export class BackendProcessSpawnError extends Schema.TaggedErrorClass()( + "BackendProcessSpawnError", + { + ...backendProcessContextSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`; } } -type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; +export class BackendProcessOutputReadError extends Schema.TaggedErrorClass()( + "BackendProcessOutputReadError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + streamName: Schema.Literals(["stdout", "stderr"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`; + } +} + +export class BackendProcessOutputHandlingError extends Schema.TaggedErrorClass()( + "BackendProcessOutputHandlingError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + streamName: Schema.Literals(["stdout", "stderr"]), + chunkByteLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`; + } +} + +export type BackendProcessOutputError = + | BackendProcessOutputReadError + | BackendProcessOutputHandlingError; + +export class BackendProcessExitStatusError extends Schema.TaggedErrorClass()( + "BackendProcessExitStatusError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the exit status of desktop backend process ${this.pid}.`; + } +} + +export class DesktopBackendRestartError extends Schema.TaggedErrorClass()( + "DesktopBackendRestartError", + { + reason: Schema.String, + delayMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`; + } +} + +export const BackendProcessError = Schema.Union([ + BackendProcessBootstrapEncodeError, + BackendProcessSpawnError, + BackendProcessExitStatusError, +]); +export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly readinessTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onReady?: () => Effect.Effect; - readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, - ) => Effect.Effect; + ) => Effect.Effect; + readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect; } export interface DesktopBackendSnapshot { @@ -106,16 +191,14 @@ export interface DesktopBackendSnapshot { readonly restartScheduled: boolean; } -export interface DesktopBackendManagerShape { - readonly start: Effect.Effect; - readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; - readonly currentConfig: Effect.Effect>; - readonly snapshot: Effect.Effect; -} - export class DesktopBackendManager extends Context.Service< DesktopBackendManager, - DesktopBackendManagerShape + { + readonly start: Effect.Effect; + readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; + readonly currentConfig: Effect.Effect>; + readonly snapshot: Effect.Effect; + } >()("@t3tools/desktop/backend/DesktopBackendManager") {} const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = @@ -176,11 +259,10 @@ const closeRun = ( ).pipe(Effect.ignore); }; -const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( - baseUrl: URL, - timeout: Duration.Duration, -): Effect.fn.Return { - const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); +export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( + options: BackendProcessContext & { readonly timeout: Duration.Duration }, +): Effect.fn.Return { + const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); const client = (yield* HttpClient.HttpClient).pipe( HttpClient.filterStatusOk, HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), @@ -189,48 +271,78 @@ const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(fu yield* client.get(readinessUrl).pipe( Effect.asVoid, - Effect.timeout(timeout), - Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), + Effect.timeout(options.timeout), + Effect.mapError( + (cause) => + new BackendReadinessTimeoutError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + readinessUrl, + timeoutMs: Duration.toMillis(options.timeout), + cause, + }), + ), ); }); -function describeProcessExit( - result: Result.Result, -): BackendProcessExit { - if (Result.isSuccess(result)) { - return { - code: Option.some(result.success), - reason: `code=${result.success}`, - result, - }; - } - - return { - code: Option.none(), - reason: result.failure.message, - result, - }; -} - function drainBackendOutput( + context: BackendProcessContext & { readonly pid: number }, streamName: BackendProcessOutputStream, stream: Stream.Stream, - onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect, + onOutput: ( + streamName: BackendProcessOutputStream, + chunk: Uint8Array, + ) => Effect.Effect, + onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect, ): Effect.Effect { return stream.pipe( - Stream.runForEach((chunk) => onOutput(streamName, chunk)), - Effect.ignore, + Stream.mapError( + (cause) => + new BackendProcessOutputReadError({ + ...context, + streamName, + cause, + }), + ), + Stream.runForEach((chunk) => + onOutput(streamName, chunk).pipe( + Effect.mapError( + (cause) => + new BackendProcessOutputHandlingError({ + ...context, + streamName, + chunkByteLength: chunk.byteLength, + cause, + }), + ), + ), + ), + Effect.catchTags({ + BackendProcessOutputReadError: onOutputFailure, + BackendProcessOutputHandlingError: onOutputFailure, + }), ); } const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); -const runBackendProcess = Effect.fn("runBackendProcess")(function* ( +export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( - Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), + Effect.mapError( + (cause) => + new BackendProcessBootstrapEncodeError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + cause, + }), + ), ); const onOutput = options.onOutput ?? (() => Effect.void); const command = ChildProcess.make( @@ -256,28 +368,78 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( }, ); - const handle = yield* spawner - .spawn(command) - .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); + const handle = yield* spawner.spawn(command).pipe( + Effect.mapError( + (cause) => + new BackendProcessSpawnError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + cause, + }), + ), + ); yield* options.onStarted?.(handle.pid) ?? Effect.void; if (options.captureOutput) { - yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); - yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + const outputContext = { + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + }; + const onOutputFailure = options.onOutputFailure ?? (() => Effect.void); + yield* drainBackendOutput( + outputContext, + "stdout", + handle.stdout, + onOutput, + onOutputFailure, + ).pipe(Effect.forkScoped); + yield* drainBackendOutput( + outputContext, + "stderr", + handle.stderr, + onOutput, + onOutputFailure, + ).pipe(Effect.forkScoped); } - yield* waitForHttpReady( - options.httpBaseUrl, - options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - ).pipe( + yield* waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, + }), Effect.forkScoped, ); - return describeProcessExit(yield* Effect.result(handle.exitCode)); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError( + (cause) => + new BackendProcessExitStatusError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + cause, + }), + ), + ); + return { + code: Option.some(exitCode), + reason: `code=${exitCode}`, + } satisfies BackendProcessExit; }); -const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { +export const make = Effect.gen(function* () { const parentScope = yield* Scope.Scope; const fileSystem = yield* FileSystem.FileSystem; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -332,7 +494,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const config = yield* configuration.resolve.pipe( Effect.tapError((error) => logBackendManagerError("failed to generate desktop backend configuration", { - cause: error.message, + cause: error, }), ), Effect.option, @@ -470,22 +632,26 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio yield* desktopWindow.handleBackendReady.pipe( Effect.catch((error) => logBackendManagerError("failed to open main window after backend readiness", { - message: error.message, + cause: error, }), ), ); }), onReadinessFailure: (error) => logBackendManagerWarning("backend readiness check failed during bootstrap", { - error: error.message, + error, }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onOutputFailure: (error) => logBackendManagerError(error.message, { error }), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => finalizeRun(error.message), + onFailure: (error) => + logBackendManagerError(error.message, { error }).pipe( + Effect.andThen(finalizeRun(error.message)), + ), onSuccess: (exit) => finalizeRun(exit.reason), }), Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), @@ -540,11 +706,17 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }), ), Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), - Effect.catchCause((cause) => - logBackendManagerError("desktop backend restart fiber failed", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopBackendRestartError({ + reason, + delayMs: Duration.toMillis(delay), + cause, + }); + return logBackendManagerError(error.message, { error }); + }), ), parentScope, ); @@ -603,4 +775,4 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); -export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager()); +export const layer = Layer.effect(DesktopBackendManager, make); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts new file mode 100644 index 00000000000..cd54c46c89a --- /dev/null +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; + +const config: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: {}, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "desktop-bootstrap-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, +}; + +describe("DesktopLocalEnvironmentAuth", () => { + it.effect("exchanges the desktop bootstrap credential only once", () => + Effect.gen(function* () { + const requestCount = yield* Ref.make(0); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Ref.update(requestCount, (count) => count + 1).pipe( + Effect.as( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ), + ), + ), + ); + const managerLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.some(config)), + snapshot: Effect.succeed({ + desiredRunning: true, + ready: true, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }), + }); + const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(managerLayer, httpClientLayer)), + ); + + const [first, second] = yield* Effect.gen(function* () { + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; + return yield* Effect.all([auth.getBearerToken, auth.getBearerToken]); + }).pipe(Effect.provide(testLayer)); + + assert.strictEqual(first, "desktop-bearer-token"); + assert.strictEqual(second, "desktop-bearer-token"); + assert.strictEqual(yield* Ref.get(requestCount), 1); + }), + ); +}); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts new file mode 100644 index 00000000000..e619b330d83 --- /dev/null +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -0,0 +1,88 @@ +import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; + +export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass()( + "DesktopLocalEnvironmentAuthBackendNotConfiguredError", + {}, +) { + override get message(): string { + return "Local backend is not configured."; + } +} + +export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.TaggedErrorClass()( + "DesktopLocalEnvironmentAuthSessionBootstrapError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to create the local desktop bearer session."; + } +} + +export const DesktopLocalEnvironmentAuthError = Schema.Union([ + DesktopLocalEnvironmentAuthBackendNotConfiguredError, + DesktopLocalEnvironmentAuthSessionBootstrapError, +]); +export type DesktopLocalEnvironmentAuthError = typeof DesktopLocalEnvironmentAuthError.Type; + +export class DesktopLocalEnvironmentAuth extends Context.Service< + DesktopLocalEnvironmentAuth, + { + readonly getBearerToken: Effect.Effect; + } +>()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} + +export const make = Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const httpClient = yield* HttpClient.HttpClient; + const tokenRef = yield* Ref.make(Option.none()); + const mutex = yield* Semaphore.make(1); + + const getBearerToken = mutex + .withPermits(1)( + Effect.gen(function* () { + const cached = yield* Ref.get(tokenRef); + if (Option.isSome(cached)) { + return cached.value; + } + + const configOption = yield* backendManager.currentConfig; + if (Option.isNone(configOption)) { + return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); + } + const config = configOption.value; + const session = yield* bootstrapRemoteBearerSession({ + httpBaseUrl: config.httpBaseUrl.href, + credential: config.bootstrap.desktopBootstrapToken, + clientMetadata: { + label: "T3 Code Desktop", + deviceType: "desktop", + }, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.mapError( + (cause) => + new DesktopLocalEnvironmentAuthSessionBootstrapError({ + cause, + }), + ), + ); + yield* Ref.set(tokenRef, Option.some(session.access_token)); + return session.access_token; + }), + ) + .pipe(Effect.withSpan("desktop.localEnvironmentAuth.getBearerToken")); + + return DesktopLocalEnvironmentAuth.of({ getBearerToken }); +}); + +export const layer = Layer.effect(DesktopLocalEnvironmentAuth, make); diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts new file mode 100644 index 00000000000..411af7553f9 --- /dev/null +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts @@ -0,0 +1,65 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { networkInterfacesMock } = vi.hoisted(() => ({ + networkInterfacesMock: vi.fn(), +})); + +vi.mock("node:os", () => ({ + networkInterfaces: networkInterfacesMock, +})); + +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; + +const TestLayer = DesktopNetworkInterfaces.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + +describe("DesktopNetworkInterfaces", () => { + beforeEach(() => { + networkInterfacesMock.mockReset(); + }); + + it.effect("reads network interfaces through the service", () => { + const interfaces = { + en0: [ + { + address: "192.168.1.10", + family: "IPv4", + internal: false, + }, + ], + }; + networkInterfacesMock.mockReturnValueOnce(interfaces); + + return Effect.gen(function* () { + const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; + assert.strictEqual(yield* service.read, interfaces); + }).pipe(Effect.provide(TestLayer)); + }); + + it.effect("preserves network interface read failures as structured defects", () => { + const cause = new Error("network interface probe failed"); + networkInterfacesMock.mockImplementationOnce(() => { + throw cause; + }); + + return Effect.gen(function* () { + const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; + const exit = yield* Effect.exit(service.read); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopNetworkInterfaces.DesktopNetworkInterfacesReadError); + assert.equal(error.platform, "linux"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to read desktop network interfaces on linux."); + } + }).pipe(Effect.provide(TestLayer)); + }); +}); diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts new file mode 100644 index 00000000000..43f634c4491 --- /dev/null +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -0,0 +1,52 @@ +import * as NodeOS from "node:os"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +export interface DesktopNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; + readonly netmask?: string; + readonly mac?: string; + readonly cidr?: string | null; + readonly scopeid?: number; +} + +export type NetworkInterfaces = Readonly< + Record +>; + +export class DesktopNetworkInterfacesReadError extends Schema.TaggedErrorClass()( + "DesktopNetworkInterfacesReadError", + { + platform: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read desktop network interfaces on ${this.platform}.`; + } +} + +export class DesktopNetworkInterfaces extends Context.Service< + DesktopNetworkInterfaces, + { + readonly read: Effect.Effect; + } +>()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {} + +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return DesktopNetworkInterfaces.of({ + read: Effect.try({ + try: () => NodeOS.networkInterfaces(), + catch: (cause) => new DesktopNetworkInterfacesReadError({ platform, cause }), + }).pipe(Effect.orDie), + }); +}); + +export const layer = Layer.effect(DesktopNetworkInterfaces, make); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index e5fbb84c8ad..8b934fd8d85 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -7,21 +7,18 @@ import * as FileSystem from "effect/FileSystem"; 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 * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { - DesktopEnvironment, - layer as makeDesktopEnvironmentLayer, -} from "../app/DesktopEnvironment.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; -import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const encoder = new TextEncoder(); -const emptyNetworkInterfaces: DesktopNetworkInterfaces = {}; -const lanNetworkInterfaces: DesktopNetworkInterfaces = { +const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {}; +const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { en0: [ { address: "192.168.1.20", @@ -31,7 +28,7 @@ const lanNetworkInterfaces: DesktopNetworkInterfaces = { ], }; -const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { +const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { tailscale0: [ { address: "100.90.1.2", @@ -72,7 +69,7 @@ function dieOnSpawnLayer() { } function makeEnvironmentLayer(baseDir: string, env: Record = {}) { - return makeDesktopEnvironmentLayer({ + return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: baseDir, platform: "darwin", @@ -91,18 +88,19 @@ function makeEnvironmentLayer(baseDir: string, env: Record; readonly spawnerLayer?: Layer.Layer; + readonly desktopSettingsLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const environmentLayer = makeEnvironmentLayer(input.baseDir, env); - const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { + const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, { read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), }); return DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer), Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()), @@ -113,18 +111,19 @@ function makeLayer(input: { } const withHarness = ( - networkInterfaces: DesktopNetworkInterfaces, + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, effect: Effect.Effect< A, E, | R - | DesktopEnvironment + | DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem | DesktopServerExposure.DesktopServerExposure | DesktopAppSettings.DesktopAppSettings >, env: Record = {}, spawnerLayer?: Layer.Layer, + desktopSettingsLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -138,6 +137,7 @@ const withHarness = ( networkInterfaces, env, ...(spawnerLayer ? { spawnerLayer } : {}), + ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), }), ), ); @@ -240,6 +240,67 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("preserves persistence request context and the settings failure chain", () => { + const diskFailure = new Error("disk exploded"); + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/desktop-settings.json", + cause: diskFailure, + }); + const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setServerExposureMode: () => Effect.fail(settingsFailure), + setTailscaleServe: () => Effect.fail(settingsFailure), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + + return withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.instanceOf( + modeError, + DesktopServerExposure.DesktopServerExposureModePersistenceError, + ); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); + assert.equal(modeError.mode, "network-accessible"); + assert.strictEqual(modeError.cause, settingsFailure); + assert.strictEqual(modeError.cause.cause, diskFailure); + assert.equal( + modeError.message, + "Failed to persist desktop server exposure mode network-accessible.", + ); + assert.notInclude(modeError.message, diskFailure.message); + + const tailscaleError = yield* serverExposure + .setTailscaleServeEnabled({ enabled: true, port: 8443 }) + .pipe(Effect.flip); + assert.instanceOf( + tailscaleError, + DesktopServerExposure.DesktopTailscaleServePersistenceError, + ); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); + assert.equal(tailscaleError.enabled, true); + assert.equal(tailscaleError.port, 8443); + assert.strictEqual(tailscaleError.cause, settingsFailure); + assert.strictEqual(tailscaleError.cause.cause, diskFailure); + assert.equal( + tailscaleError.message, + "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", + ); + assert.notInclude(tailscaleError.message, diskFailure.message); + }), + {}, + undefined, + settingsLayer, + ); + }); + it.effect("resolves advertised endpoints from the scoped runtime state", () => withHarness( { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 8b62323499e..f04d2af7b1f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -1,50 +1,35 @@ -import * as NodeOS from "node:os"; - import { createAdvertisedEndpoint, type CreateAdvertisedEndpointInput, } from "@t3tools/shared/advertisedEndpoint"; -import type { - AdvertisedEndpoint, - AdvertisedEndpointProvider, - DesktopServerExposureMode, - DesktopServerExposureState, +import { + DesktopServerExposureModeSchema, + type AdvertisedEndpoint, + type AdvertisedEndpointProvider, + type DesktopServerExposureMode, + type DesktopServerExposureState, } from "@t3tools/contracts"; +import { readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; -import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; -export interface DesktopNetworkInterfaceInfo { - readonly address: string; - readonly family: string | number; - readonly internal: boolean; - readonly netmask?: string; - readonly mac?: string; - readonly cidr?: string | null; - readonly scopeid?: number; -} - -export type DesktopNetworkInterfaces = Readonly< - Record ->; - interface ResolvedDesktopServerExposure { readonly mode: DesktopServerExposureMode; readonly bindHost: string; @@ -91,7 +76,7 @@ const isHttpsEndpointUrl = (value: string): boolean => { }; const resolveLanAdvertisedHost = ( - networkInterfaces: DesktopNetworkInterfaces, + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, explicitHost: string | undefined, ): string | null => { const normalizedExplicitHost = normalizeOptionalHost(explicitHost); @@ -116,7 +101,7 @@ const resolveLanAdvertisedHost = ( const resolveDesktopServerExposure = (input: { readonly mode: DesktopServerExposureMode; readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; + readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; readonly advertisedHostOverride?: string; }): ResolvedDesktopServerExposure => { const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; @@ -218,34 +203,56 @@ const resolveDesktopCoreAdvertisedEndpoints = ( return endpoints; }; -type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; - -export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( +export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErrorClass()( "DesktopServerExposureNoNetworkAddressError", -)<{ - readonly port: number; -}> { - override get message() { + { + port: Schema.Number, + }, +) { + override get message(): string { return `No reachable network address is available for desktop network access on port ${this.port}.`; } } -export class DesktopServerExposurePersistenceError extends Data.TaggedError( - "DesktopServerExposurePersistenceError", -)<{ - readonly operation: DesktopServerExposurePersistenceOperation; - readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; -}> { - override get message() { - return `Failed to persist desktop ${this.operation} settings.`; +export class DesktopServerExposureModePersistenceError extends Schema.TaggedErrorClass()( + "DesktopServerExposureModePersistenceError", + { + mode: DesktopServerExposureModeSchema, + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist desktop server exposure mode ${this.mode}.`; } } -export type DesktopServerExposureSetModeError = - | DesktopServerExposureNoNetworkAddressError - | DesktopServerExposurePersistenceError; +export class DesktopTailscaleServePersistenceError extends Schema.TaggedErrorClass()( + "DesktopTailscaleServePersistenceError", + { + enabled: Schema.Boolean, + port: Schema.NullOr(Schema.Number), + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist desktop Tailscale Serve settings (enabled: ${this.enabled}, port: ${this.port ?? "unchanged"}).`; + } +} -export type DesktopServerExposureError = DesktopServerExposureSetModeError; +export const DesktopServerExposureSetModeError = Schema.Union([ + DesktopServerExposureNoNetworkAddressError, + DesktopServerExposureModePersistenceError, +]); +export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; +export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); + +export const DesktopServerExposureError = Schema.Union([ + DesktopServerExposureNoNetworkAddressError, + DesktopServerExposureModePersistenceError, + DesktopTailscaleServePersistenceError, +]); +export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; +export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; @@ -260,36 +267,25 @@ export interface DesktopServerExposureChange { readonly requiresRelaunch: boolean; } -export interface DesktopServerExposureShape { - readonly getState: Effect.Effect; - readonly backendConfig: Effect.Effect; - readonly configureFromSettings: (input: { - readonly port: number; - }) => Effect.Effect; - readonly setMode: ( - mode: DesktopServerExposureMode, - ) => Effect.Effect; - readonly setTailscaleServeEnabled: (input: { - readonly enabled: boolean; - readonly port?: number; - }) => Effect.Effect; - readonly getAdvertisedEndpoints: Effect.Effect; -} - export class DesktopServerExposure extends Context.Service< DesktopServerExposure, - DesktopServerExposureShape + { + readonly getState: Effect.Effect; + readonly backendConfig: Effect.Effect; + readonly configureFromSettings: (input: { + readonly port: number; + }) => Effect.Effect; + readonly setMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServeEnabled: (input: { + readonly enabled: boolean; + readonly port?: number; + }) => Effect.Effect; + readonly getAdvertisedEndpoints: Effect.Effect; + } >()("@t3tools/desktop/backend/DesktopServerExposure") {} -export interface DesktopNetworkInterfacesServiceShape { - readonly read: Effect.Effect; -} - -export class DesktopNetworkInterfacesService extends Context.Service< - DesktopNetworkInterfacesService, - DesktopNetworkInterfacesServiceShape ->()("@t3tools/desktop/backend/DesktopServerExposure/DesktopNetworkInterfacesService") {} - interface RuntimeState { readonly requestedMode: DesktopServerExposureMode; readonly mode: DesktopServerExposureMode; @@ -311,10 +307,10 @@ interface ResolvedRuntimeState { const initialRuntimeState = (): RuntimeState => runtimeStateFromResolvedExposure({ - requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, - settings: DEFAULT_DESKTOP_SETTINGS, + requestedMode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, exposure: resolveDesktopServerExposure({ - mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + mode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, port: 0, networkInterfaces: {}, }), @@ -348,7 +344,7 @@ const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure function runtimeStateFromResolvedExposure(input: { readonly requestedMode: DesktopServerExposureMode; - readonly settings: DesktopSettings; + readonly settings: DesktopAppSettings.DesktopSettings; readonly exposure: ResolvedDesktopServerExposure; readonly port: number; }): RuntimeState { @@ -369,9 +365,9 @@ function runtimeStateFromResolvedExposure(input: { function resolveRuntimeState(input: { readonly requestedMode: DesktopServerExposureMode; - readonly settings: DesktopSettings; + readonly settings: DesktopAppSettings.DesktopSettings; readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; + readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; readonly advertisedHostOverride: Option.Option; }): ResolvedRuntimeState { const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); @@ -408,12 +404,12 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo previous.bindHost !== next.bindHost || previous.localHttpUrl !== next.localHttpUrl; -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; - const networkInterfaces = yield* DesktopNetworkInterfacesService; + const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App @@ -476,8 +472,8 @@ const make = Effect.gen(function* () { const change = yield* desktopSettings.setServerExposureMode(mode).pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "server-exposure-mode", + new DesktopServerExposureModePersistenceError({ + mode, cause, }), ), @@ -504,8 +500,9 @@ const make = Effect.gen(function* () { .pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "tailscale-serve", + new DesktopTailscaleServePersistenceError({ + enabled: input.enabled, + port: input.port ?? null, cause, }), ), @@ -564,10 +561,3 @@ const make = Effect.gen(function* () { }); export const layer = Layer.effect(DesktopServerExposure, make); - -export const networkInterfacesLayer = Layer.succeed( - DesktopNetworkInterfacesService, - DesktopNetworkInterfacesService.of({ - read: Effect.sync(() => NodeOS.networkInterfaces()), - }), -); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 50706923fb3..0b48adc308c 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -9,10 +9,10 @@ import { } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; +import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; @@ -25,7 +25,7 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { function resolveTailscaleIpAdvertisedEndpoints(input: { readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; + readonly networkInterfaces: NetworkInterfaces; }): readonly AdvertisedEndpoint[] { const seen = new Set(); const endpoints: AdvertisedEndpoint[] = []; @@ -103,7 +103,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd readonly port: number; readonly serveEnabled?: boolean; readonly servePort?: number; - readonly networkInterfaces: DesktopNetworkInterfaces; + readonly networkInterfaces: NetworkInterfaces; readonly statusJson?: string | null; readonly readMagicDnsName?: Effect.Effect< string | null, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index f6ed5cb1df7..f3ce3b4b5f4 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -100,6 +100,44 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); + it.effect("reports which app metadata property failed", () => + Effect.gen(function* () { + const cause = new Error("version unavailable"); + getVersionMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronApp = yield* ElectronApp.ElectronApp; + const error = yield* electronApp.metadata.pipe(Effect.flip); + + assert.instanceOf(error, ElectronApp.ElectronAppMetadataReadError); + assert.strictEqual(error.property, "app-version"); + assert.strictEqual(error.cause, cause); + assert.strictEqual( + error.message, + 'Failed to read Electron app metadata property "app-version".', + ); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("preserves Electron readiness failures", () => + Effect.gen(function* () { + const cause = new Error("ready failed"); + whenReadyMock.mockRejectedValueOnce(cause); + + const electronApp = yield* ElectronApp.ElectronApp; + const error = yield* electronApp.whenReady.pipe(Effect.flip); + + assert.instanceOf(error, ElectronApp.ElectronAppWhenReadyError); + assert.strictEqual(error.isPackaged, true); + assert.strictEqual(error.cause, cause); + assert.strictEqual( + error.message, + "Failed to wait for the Electron app to become ready (packaged: true).", + ); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + it.effect("scopes app event listeners", () => Effect.gen(function* () { const listener = vi.fn(); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 49b432fd5dd..0af8691f6c4 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; @@ -13,41 +14,64 @@ export interface ElectronAppMetadata { readonly runningUnderArm64Translation: boolean; } -export interface ElectronAppShape { - readonly metadata: Effect.Effect; - readonly name: Effect.Effect; - readonly whenReady: Effect.Effect; - readonly quit: Effect.Effect; - readonly exit: (code: number) => Effect.Effect; - readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; - readonly setPath: ( - name: Parameters[0], - path: string, - ) => Effect.Effect; - readonly setName: (name: string) => Effect.Effect; - readonly setAboutPanelOptions: ( - options: Electron.AboutPanelOptionsOptions, - ) => Effect.Effect; - readonly setAppUserModelId: (id: string) => Effect.Effect; - readonly requestSingleInstanceLock: Effect.Effect; - readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; - readonly setAsDefaultProtocolClient: ( - protocol: string, - path?: string, - args?: readonly string[], - ) => Effect.Effect; - readonly setDesktopName: (desktopName: string) => Effect.Effect; - readonly setDockIcon: (iconPath: string) => Effect.Effect; - readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; - readonly on: >( - eventName: string, - listener: (...args: Args) => void, - ) => Effect.Effect; +export class ElectronAppMetadataReadError extends Schema.TaggedErrorClass()( + "ElectronAppMetadataReadError", + { + property: Schema.Literals(["app-version", "app-path"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read Electron app metadata property "${this.property}".`; + } } -export class ElectronApp extends Context.Service()( - "@t3tools/desktop/electron/ElectronApp", -) {} +export class ElectronAppWhenReadyError extends Schema.TaggedErrorClass()( + "ElectronAppWhenReadyError", + { + isPackaged: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to wait for the Electron app to become ready (packaged: ${this.isPackaged}).`; + } +} + +export class ElectronApp extends Context.Service< + ElectronApp, + { + readonly metadata: Effect.Effect; + readonly name: Effect.Effect; + readonly whenReady: Effect.Effect; + readonly quit: Effect.Effect; + readonly exit: (code: number) => Effect.Effect; + readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; + readonly setPath: ( + name: Parameters[0], + path: string, + ) => Effect.Effect; + readonly setName: (name: string) => Effect.Effect; + readonly setAboutPanelOptions: ( + options: Electron.AboutPanelOptionsOptions, + ) => Effect.Effect; + readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; + readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; + readonly setAsDefaultProtocolClient: ( + protocol: string, + path?: string, + args?: readonly string[], + ) => Effect.Effect; + readonly setDesktopName: (desktopName: string) => Effect.Effect; + readonly setDockIcon: (iconPath: string) => Effect.Effect; + readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronApp") {} const addScopedAppListener = >( eventName: string, @@ -63,16 +87,41 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -const make = ElectronApp.of({ - metadata: Effect.sync(() => ({ - appVersion: Electron.app.getVersion(), - appPath: Electron.app.getAppPath(), - isPackaged: Electron.app.isPackaged, - resourcesPath: process.resourcesPath, - runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, - })), +export const make = ElectronApp.of({ + metadata: Effect.gen(function* () { + const appVersion = yield* Effect.try({ + try: () => Electron.app.getVersion(), + catch: (cause) => + new ElectronAppMetadataReadError({ + property: "app-version", + cause, + }), + }); + const appPath = yield* Effect.try({ + try: () => Electron.app.getAppPath(), + catch: (cause) => + new ElectronAppMetadataReadError({ + property: "app-path", + cause, + }), + }); + + return { + appVersion, + appPath, + isPackaged: Electron.app.isPackaged, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, + }; + }), name: Effect.sync(() => Electron.app.name), - whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), + whenReady: Effect.gen(function* () { + const isPackaged = Electron.app.isPackaged; + yield* Effect.tryPromise({ + try: () => Electron.app.whenReady(), + catch: (cause) => new ElectronAppWhenReadyError({ isPackaged, cause }), + }); + }), quit: Effect.sync(() => { Electron.app.quit(); }), diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 9be62e740b2..388b3fd2c15 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { BrowserWindow } from "electron"; @@ -90,4 +91,116 @@ describe("ElectronDialog", () => { ]); }).pipe(Effect.provide(ElectronDialog.layer)), ); + + it.effect("preserves folder picker request context and cause", () => + Effect.gen(function* () { + const cause = new Error("folder picker failed"); + const owner = { id: 7 } as BrowserWindow; + showOpenDialogMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.pickFolder({ + owner: Option.some(owner), + defaultPath: Option.some("/workspace"), + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); + assert.isTrue(ElectronDialog.isElectronDialogError(error)); + assert.strictEqual(error.ownerWindowId, 7); + assert.strictEqual(error.defaultPath, "/workspace"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "window 7"); + assert.include(error.message, "/workspace"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves confirmation request context and cause", () => + Effect.gen(function* () { + const cause = new Error("confirmation failed"); + const owner = { id: 9 } as BrowserWindow; + showMessageBoxMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.confirm({ + owner: Option.some(owner), + message: " Confirm removal? ", + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError); + assert.strictEqual(error.ownerWindowId, 9); + assert.strictEqual(error.promptLength, "Confirm removal?".length); + assert.notProperty(error, "promptMessage"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "window 9"); + assert.notInclude(error.message, "Confirm removal?"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves message box request context and cause", () => + Effect.gen(function* () { + const cause = new Error("message box failed"); + showMessageBoxMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.showMessageBox({ + type: "warning", + title: "Unsaved changes", + message: "Discard changes?", + detail: "This cannot be undone.", + buttons: ["Cancel", "Discard"], + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogShowMessageBoxError); + assert.strictEqual(error.type, "warning"); + assert.strictEqual(error.titleLength, "Unsaved changes".length); + assert.strictEqual(error.messageLength, "Discard changes?".length); + assert.strictEqual(error.detailLength, "This cannot be undone.".length); + assert.strictEqual(error.buttonCount, 2); + assert.notProperty(error, "title"); + assert.notProperty(error, "dialogMessage"); + assert.notProperty(error, "dialogDetail"); + assert.notProperty(error, "buttons"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "warning"); + assert.notInclude(error.message, "Unsaved changes"); + assert.notInclude(error.message, "Discard changes?"); + assert.notInclude(error.message, "This cannot be undone."); + assert.notInclude(error.message, "Cancel"); + assert.notInclude(error.message, "Discard"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves error box request context and cause in the defect", () => + Effect.gen(function* () { + const cause = new Error("error box failed"); + showErrorBoxMock.mockImplementation(() => { + throw cause; + }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const exit = yield* Effect.exit(dialog.showErrorBox("Startup failed", "Could not start.")); + + assert.isTrue(exit._tag === "Failure"); + if (exit._tag === "Success") return; + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronDialog.ElectronDialogShowErrorBoxError); + assert.strictEqual(error.titleLength, "Startup failed".length); + assert.strictEqual(error.contentLength, "Could not start.".length); + assert.notProperty(error, "title"); + assert.notProperty(error, "content"); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, "Startup failed"); + assert.notInclude(error.message, "Could not start."); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 74e6ae58848..be633971bea 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -2,11 +2,80 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; const CONFIRM_BUTTON_INDEX = 1; +export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickFolderError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + defaultPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath; + return `Failed to open the Electron folder picker for ${owner} with ${defaultPath}.`; + } +} + +export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( + "ElectronDialogConfirmError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + promptLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + return `Failed to open an Electron confirmation dialog for ${owner} with a ${this.promptLength}-character prompt.`; + } +} + +export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass()( + "ElectronDialogShowMessageBoxError", + { + type: Schema.NullOr(Schema.Literals(["none", "info", "error", "question", "warning"])), + titleLength: Schema.NullOr(Schema.Number), + messageLength: Schema.Number, + detailLength: Schema.NullOr(Schema.Number), + buttonCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const type = this.type === null ? "untyped" : this.type; + return `Failed to show the Electron ${type} message box with ${this.buttonCount} buttons.`; + } +} + +export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass()( + "ElectronDialogShowErrorBoxError", + { + titleLength: Schema.Number, + contentLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to show the Electron error box with a ${this.titleLength}-character title and ${this.contentLength}-character content.`; + } +} + +export const ElectronDialogError = Schema.Union([ + ElectronDialogPickFolderError, + ElectronDialogConfirmError, + ElectronDialogShowMessageBoxError, + ElectronDialogShowErrorBoxError, +]); +export type ElectronDialogError = typeof ElectronDialogError.Type; +export const isElectronDialogError = Schema.is(ElectronDialogError); + export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; readonly defaultPath: Option.Option; @@ -17,23 +86,29 @@ export interface ElectronDialogConfirmInput { readonly message: string; } -export interface ElectronDialogShape { - readonly pickFolder: ( - input: ElectronDialogPickFolderInput, - ) => Effect.Effect>; - readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; - readonly showMessageBox: ( - options: Electron.MessageBoxOptions, - ) => Effect.Effect; - readonly showErrorBox: (title: string, content: string) => Effect.Effect; -} - -export class ElectronDialog extends Context.Service()( - "@t3tools/desktop/electron/ElectronDialog", -) {} +export class ElectronDialog extends Context.Service< + ElectronDialog, + { + readonly pickFolder: ( + input: ElectronDialogPickFolderInput, + ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly confirm: ( + input: ElectronDialogConfirmInput, + ) => Effect.Effect; + readonly showMessageBox: ( + options: Electron.MessageBoxOptions, + ) => Effect.Effect; + readonly showErrorBox: (title: string, content: string) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronDialog") {} -const make = ElectronDialog.of({ +export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { onNone: () => ({ properties: ["openDirectory", "createDirectory"], @@ -43,10 +118,18 @@ const make = ElectronDialog.of({ defaultPath, }), }); - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), - onSome: (owner) => - Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFolderError({ + ownerWindowId, + defaultPath, + cause, + }), }); if (result.canceled) { @@ -68,17 +151,48 @@ const make = ElectronDialog.of({ noLink: true, message: normalizedMessage, }; - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), - onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showMessageBox(options), + onSome: (owner) => Electron.dialog.showMessageBox(owner, options), + }), + catch: (cause) => + new ElectronDialogConfirmError({ + ownerWindowId, + promptLength: normalizedMessage.length, + cause, + }), }); return result.response === CONFIRM_BUTTON_INDEX; }), - showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), - showErrorBox: (title, content) => - Effect.sync(() => { - Electron.dialog.showErrorBox(title, content); + showMessageBox: (options) => + Effect.tryPromise({ + try: () => Electron.dialog.showMessageBox(options), + catch: (cause) => + new ElectronDialogShowMessageBoxError({ + type: options.type ?? null, + titleLength: options.title?.length ?? null, + messageLength: options.message.length, + detailLength: options.detail?.length ?? null, + buttonCount: options.buttons?.length ?? 0, + cause, + }), }), + showErrorBox: (title, content) => + Effect.try({ + try: () => Electron.dialog.showErrorBox(title, content), + catch: (cause) => + new ElectronDialogShowErrorBoxError({ + titleLength: title.length, + contentLength: content.length, + cause, + }), + }).pipe(Effect.orDie), }); export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 4dd8066e3c6..3dc218d8252 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -1,5 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; @@ -24,6 +27,10 @@ vi.mock("electron", () => ({ import * as ElectronMenu from "./ElectronMenu.ts"; +const TestLayer = ElectronMenu.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); @@ -42,7 +49,7 @@ describe("ElectronMenu", () => { assert.isTrue(Option.isNone(selectedItemId)); assert.equal(buildFromTemplateMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("resolves with the clicked leaf item id", () => @@ -69,7 +76,7 @@ describe("ElectronMenu", () => { }); assert.equal(Option.getOrNull(selectedItemId), "copy"); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("resolves with none when the menu closes without a click", () => @@ -93,7 +100,7 @@ describe("ElectronMenu", () => { enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("defers popupTemplate side effects until the returned Effect runs", () => @@ -114,6 +121,89 @@ describe("ElectronMenu", () => { assert.equal(buildFromTemplateMock.mock.calls.length, 1); assert.equal(popupMock.mock.calls.length, 1); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves application-menu failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("application menu build failed"); + buildFromTemplateMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.setApplicationMenu([{ label: "File" }, { label: "Edit" }]), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "set-application-menu"); + assert.equal(error.platform, "linux"); + assert.isNull(error.windowId); + assert.equal(error.itemCount, 2); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves popup-template failures with window context", () => + Effect.gen(function* () { + const cause = new Error("popup failed"); + buildFromTemplateMock.mockReturnValueOnce({ + popup: () => { + throw cause; + }, + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.popupTemplate({ + window: { id: 41 } as Electron.BrowserWindow, + template: [{ label: "Copy" }], + }), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "popup-template"); + assert.equal(error.windowId, 41); + assert.equal(error.itemCount, 1); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves context-menu failures with normalized item context", () => + Effect.gen(function* () { + const cause = new Error("context menu build failed"); + buildFromTemplateMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.showContextMenu({ + window: { id: 42 } as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.none(), + }), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "show-context-menu"); + assert.equal(error.windowId, 42); + assert.equal(error.itemCount, 1); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 2ffda3dc507..09fb5d1807d 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,11 +1,12 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export interface ElectronMenuPosition { readonly x: number; @@ -23,19 +24,40 @@ export interface ElectronMenuTemplateInput { readonly template: readonly Electron.MenuItemConstructorOptions[]; } -export interface ElectronMenuShape { - readonly setApplicationMenu: ( - template: readonly Electron.MenuItemConstructorOptions[], - ) => Effect.Effect; - readonly showContextMenu: ( - input: ElectronMenuContextInput, - ) => Effect.Effect>; - readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; +const ElectronMenuOperation = Schema.Literals([ + "set-application-menu", + "popup-template", + "show-context-menu", +]); + +export class ElectronMenuOperationError extends Schema.TaggedErrorClass()( + "ElectronMenuOperationError", + { + operation: ElectronMenuOperation, + platform: Schema.String, + windowId: Schema.NullOr(Schema.Number), + itemCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const window = this.windowId === null ? "" : ` for window ${this.windowId}`; + return `Electron menu operation ${JSON.stringify(this.operation)} failed${window} with ${this.itemCount} items on ${this.platform}.`; + } } -export class ElectronMenu extends Context.Service()( - "@t3tools/desktop/electron/ElectronMenu", -) {} +export class ElectronMenu extends Context.Service< + ElectronMenu, + { + readonly setApplicationMenu: ( + template: readonly Electron.MenuItemConstructorOptions[], + ) => Effect.Effect; + readonly showContextMenu: ( + input: ElectronMenuContextInput, + ) => Effect.Effect>; + readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronMenu") {} function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { const normalizedItems: ContextMenuItem[] = []; @@ -80,98 +102,117 @@ const normalizePosition = ( ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); -export const layer = Layer.effect( - ElectronMenu, - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - let destructiveMenuIconCache: Option.Option | undefined; +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + let destructiveMenuIconCache: Option.Option | undefined; - const getDestructiveMenuIcon = (): Option.Option => { - if (platform !== "darwin") { - return Option.none(); - } - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache; - } + const getDestructiveMenuIcon = (): Option.Option => { + if (platform !== "darwin") { + return Option.none(); + } + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; + } - try { - const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ - width: 12, - height: 12, - }); - icon.setTemplateImage(true); - destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); - } catch { - destructiveMenuIconCache = Option.none(); - } + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + icon.setTemplateImage(true); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); + } - return destructiveMenuIconCache; - }; + return destructiveMenuIconCache; + }; - const buildTemplate = ( - entries: readonly ContextMenuItem[], - complete: (selectedItemId: Option.Option) => void, - ): Electron.MenuItemConstructorOptions[] => { - const template: Electron.MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; - const itemOption: Electron.MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children, complete); - } else { - itemOption.click = () => complete(Option.some(item.id)); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (Option.isSome(destructiveIcon)) { - itemOption.icon = destructiveIcon.value; - } - } + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } - template.push(itemOption); + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } } - return template; - }; + template.push(itemOption); + } - return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.sync(() => { + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.try({ + try: () => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), - popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; - } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), - showContextMenu: (input) => - Effect.callback>((resume) => { - const normalizedItems = normalizeContextMenuItems(input.items); - if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); + }, + catch: (cause) => + new ElectronMenuOperationError({ + operation: "set-application-menu", + platform, + windowId: null, + itemCount: template.length, + cause, + }), + }).pipe(Effect.orDie), + popupTemplate: (input) => + input.template.length === 0 + ? Effect.void + : Effect.try({ + try: () => + Electron.Menu.buildFromTemplate([...input.template]).popup({ + window: input.window, + }), + catch: (cause) => + new ElectronMenuOperationError({ + operation: "popup-template", + platform, + windowId: input.window.id, + itemCount: input.template.length, + cause, + }), + }).pipe(Effect.orDie), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); + return; + } + + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { return; } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; - let completed = false; - const complete = (selectedItemId: Option.Option) => { - if (completed) { - return; - } - completed = true; - resume(Effect.succeed(selectedItemId)); - }; - + try { const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); const popupPosition = normalizePosition(input.position); const popupOptions = Option.match(popupPosition, { @@ -187,7 +228,25 @@ export const layer = Layer.effect( }), }); menu.popup(popupOptions); - }), - }); - }), -); + } catch (cause) { + if (completed) { + return; + } + completed = true; + resume( + Effect.die( + new ElectronMenuOperationError({ + operation: "show-context-menu", + platform, + windowId: input.window.id, + itemCount: normalizedItems.length, + cause, + }), + ), + ); + } + }), + }); +}); + +export const layer = Layer.effect(ElectronMenu, make); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 2306c101c63..56fe009fee2 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,105 +1,187 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; -const { registerFileProtocolMock, registerSchemesAsPrivilegedMock, unregisterProtocolMock } = - vi.hoisted(() => ({ - registerFileProtocolMock: vi.fn(), - registerSchemesAsPrivilegedMock: vi.fn(), - unregisterProtocolMock: vi.fn(), - })); +const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + netFetchMock: vi.fn(), + unhandleMock: vi.fn(), +})); vi.mock("electron", () => ({ - protocol: { - registerFileProtocol: registerFileProtocolMock, - registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, - unregisterProtocol: unregisterProtocolMock, - }, + net: { fetch: netFetchMock }, + protocol: { handle: handleMock, unhandle: unhandleMock }, })); import * as ElectronProtocol from "./ElectronProtocol.ts"; describe("ElectronProtocol", () => { beforeEach(() => { - registerFileProtocolMock.mockReset(); - registerSchemesAsPrivilegedMock.mockReset(); - unregisterProtocolMock.mockReset(); + handleMock.mockReset(); + netFetchMock.mockReset(); + unhandleMock.mockReset(); }); - it("normalizes safe desktop protocol pathnames", () => { - assert.equal( - Option.getOrNull(ElectronProtocol.normalizeDesktopProtocolPathname("/settings/./general")), - "settings/general", - ); - assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret"))); - }); + it.effect("proxies the stable renderer origin to the current app server", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockResolvedValue(new Response("ok")); - it.effect("registers desktop scheme privileges through a layer", () => - Effect.scoped( - Layer.build(ElectronProtocol.layerSchemePrivileges).pipe( - Effect.andThen( - Effect.sync(() => { - assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ - [ - [ - { - scheme: "t3", - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - }, - }, - ], - ], - ]); - }), - ), - ), - ), + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3774/"), + clerkFrontendApiHostname: "clerk.t3.codes", + }); + assert.isDefined(handler); + + const response = yield* Effect.promise(() => + handler!(new Request("t3code-dev://app/api/health?verbose=1")), + ); + assert.equal(yield* Effect.promise(() => response.text()), "ok"); + assert.include( + response.headers.get("content-security-policy") ?? "", + "script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com", + ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "connect-src 'self' http: https: ws: wss:", + ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "img-src 'self' t3code-dev: blob: data: http: https:", + ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "font-src 'self' t3code-dev: data:", + ); + }), + ); + + assert.deepEqual( + handleMock.mock.calls.map((call) => call[0]), + ["t3code-dev"], + ); + assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1"); + assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); + }).pipe(Effect.provide(ElectronProtocol.layer)), ); - it.effect("scopes registered file protocols", () => + it.effect("rejects custom protocol requests for another host", () => Effect.gen(function* () { - let capturedHandler: - | (( - request: Electron.ProtocolRequest, - callback: (response: Electron.ProtocolResponse) => void, - ) => void) - | undefined; - - registerFileProtocolMock.mockImplementation((_scheme, handler) => { - capturedHandler = handler; - return true; + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; }); const response = yield* Effect.scoped( Effect.gen(function* () { - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - yield* electronProtocol.registerFileProtocol({ - scheme: "t3", - handler: () => Effect.succeed({ path: "/app/index.html" }), - }); - - assert.isDefined(capturedHandler); - return yield* Effect.callback((resume) => { - capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, (response) => - resume(Effect.succeed(response)), - ); + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3773/"), + clerkFrontendApiHostname: undefined, }); + return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); }), ); - assert.deepEqual(response, { path: "/app/index.html" }); - assert.deepEqual( - registerFileProtocolMock.mock.calls.map((call) => call[0]), - ["t3"], + assert.equal(response.status, 404); + assert.equal(netFetchMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("preserves protocol registration failures", () => + Effect.gen(function* () { + const cause = new Error("protocol registration failed"); + handleMock.mockImplementationOnce(() => { + throw cause; + }); + + const protocol = yield* ElectronProtocol.ElectronProtocol; + const error = yield* Effect.scoped( + protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3774/"), + clerkFrontendApiHostname: undefined, + }), + ).pipe(Effect.flip); + + assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError); + assert.equal(error.scheme, "t3code-dev"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("preserves protocol unregistration failures", () => + Effect.gen(function* () { + const cause = new Error("protocol unregistration failed"); + unhandleMock.mockImplementationOnce(() => { + throw cause; + }); + + const protocol = yield* ElectronProtocol.ElectronProtocol; + const exit = yield* Effect.exit( + Effect.scoped( + protocol.registerDesktopProtocol({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3773/"), + clerkFrontendApiHostname: undefined, + }), + ), ); - assert.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError); + assert.equal(error.scheme, "t3code"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); + } }).pipe(Effect.provide(ElectronProtocol.layer)), ); + + it("keeps executable sources host-restricted while allowing runtime network resources", () => { + const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3773/"), + clerkFrontendApiHostname: "clerk.t3.codes", + }); + const directives = Object.fromEntries( + policy.split("; ").map((directive) => { + const [name, ...sources] = directive.split(" "); + return [name, sources]; + }), + ); + + assert.deepEqual(directives["script-src"], [ + "'self'", + "'unsafe-inline'", + "https://clerk.t3.codes", + "https://challenges.cloudflare.com", + ]); + assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]); + assert.deepEqual(directives["img-src"], [ + "'self'", + "t3code:", + "blob:", + "data:", + "http:", + "https:", + ]); + assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); + }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index a56e442ddcb..757c26178d0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,272 +1,163 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; 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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; -import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts"; +export const DESKTOP_HOST = "app"; +export const DESKTOP_PRODUCTION_SCHEME = "t3code"; +export const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; -export const DESKTOP_SCHEME = "t3"; - -export class ElectronProtocolRegistrationError extends Data.TaggedError( - "ElectronProtocolRegistrationError", -)<{ - readonly scheme: string; - readonly cause: unknown; -}> { - override get message() { - return `Failed to register ${this.scheme}: file protocol.`; - } +export function getDesktopScheme(isDevelopment: boolean): string { + return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; } -export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( - "ElectronProtocolStaticBundleMissingError", -)<{}> { - override get message() { - return "Desktop static bundle missing. Build apps/server (with bundled client) first."; - } +export function getDesktopOrigin(isDevelopment: boolean): string { + return `${getDesktopScheme(isDevelopment)}://${DESKTOP_HOST}`; } -export interface ElectronProtocolShape { - readonly registerFileProtocol: (input: { - readonly scheme: string; - readonly handler: ( - request: Electron.ProtocolRequest, - ) => Effect.Effect; - readonly onFailure?: ( - request: Electron.ProtocolRequest, - cause: Cause.Cause, - ) => Electron.ProtocolResponse; - }) => Effect.Effect; - readonly registerDesktopFileProtocol: Effect.Effect< - void, - ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError, - FileSystem.FileSystem | DesktopEnvironment | Scope.Scope - >; +export function getDesktopUrl(isDevelopment: boolean): string { + return `${getDesktopOrigin(isDevelopment)}/`; } -export class ElectronProtocol extends Context.Service()( - "@t3tools/desktop/electron/ElectronProtocol", -) {} - -export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option { - const segments: string[] = []; - for (const segment of rawPath.split("/")) { - if (segment.length === 0 || segment === ".") { - continue; - } - if (segment === "..") { - return Option.none(); - } - segments.push(segment); +export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( + "ElectronProtocolRegistrationError", + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to register Electron protocol scheme "${this.scheme}".`; } - return Option.some(segments.join("/")); } -const registerDesktopSchemePrivileges = Effect.sync(() => { - Electron.protocol.registerSchemesAsPrivileged([ - { - scheme: DESKTOP_SCHEME, - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - }, - }, - ]); -}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges")); - -export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); - -const resolveDesktopStaticDir: Effect.Effect< - Option.Option, - never, - FileSystem.FileSystem | DesktopEnvironment -> = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const candidates = [ - environment.path.join(environment.appRoot, "apps/server/dist/client"), - environment.path.join(environment.appRoot, "apps/web/dist"), - ]; - for (const candidate of candidates) { - const hasIndex = yield* fileSystem - .exists(environment.path.join(candidate, "index.html")) - .pipe(Effect.orElseSucceed(() => false)); - if (hasIndex) { - return Option.some(candidate); - } +export class ElectronProtocolUnregistrationError extends Schema.TaggedErrorClass()( + "ElectronProtocolUnregistrationError", + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to unregister Electron protocol scheme "${this.scheme}".`; } - return Option.none(); -}); +} -const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")( - function* ( - staticRoot: string, - requestUrl: string, - ): Effect.fn.Return { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const url = new URL(requestUrl); - const rawPath = decodeURIComponent(url.pathname); - const normalizedPath = normalizeDesktopProtocolPathname(rawPath); - if (Option.isNone(normalizedPath)) { - return environment.path.join(staticRoot, "index.html"); - } +export interface DesktopProtocolRegistrationInput { + readonly scheme: string; + readonly targetOrigin: URL; + readonly backendOrigin: URL; + readonly clerkFrontendApiHostname: string | undefined; +} - const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; - const resolvedPath = environment.path.join(staticRoot, requestedPath); +export class ElectronProtocol extends Context.Service< + ElectronProtocol, + { + readonly registerDesktopProtocol: ( + input: DesktopProtocolRegistrationInput, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronProtocol") {} + +export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { + const clerkOrigin = input.clerkFrontendApiHostname + ? `https://${input.clerkFrontendApiHostname}` + : undefined; + const scriptSources = [ + "'self'", + "'unsafe-inline'", + ...(clerkOrigin ? [clerkOrigin] : []), + "https://challenges.cloudflare.com", + ]; - if (environment.path.extname(resolvedPath)) { - return resolvedPath; - } + // The renderer connects directly to user-configured environments in addition to + // the build-configured Clerk, relay, and OTLP endpoints. Those environment + // origins are not known when this response policy is created, so restrict + // connections by the network schemes the client supports instead of by host. + const connectSources = ["'self'", "http:", "https:", "ws:", "wss:"]; + + return [ + "default-src 'self'", + `script-src ${scriptSources.join(" ")}`, + `connect-src ${connectSources.join(" ")}`, + `img-src 'self' ${input.scheme}: blob: data: http: https:`, + "style-src 'self' 'unsafe-inline'", + `font-src 'self' ${input.scheme}: data:`, + "worker-src 'self' blob:", + "frame-src 'self' https://challenges.cloudflare.com", + "form-action 'self'", + ].join("; "); +} - const nestedIndex = environment.path.join(resolvedPath, "index.html"); - const nestedIndexExists = yield* fileSystem - .exists(nestedIndex) - .pipe(Effect.orElseSucceed(() => false)); - if (nestedIndexExists) { - return nestedIndex; - } +function withContentSecurityPolicy(response: Response, policy: string): Response { + const headers = new Headers(response.headers); + headers.set("Content-Security-Policy", policy); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} - return environment.path.join(staticRoot, "index.html"); - }, -); +async function proxyRequest( + request: Request, + targetOrigin: URL, + contentSecurityPolicy: string, +): Promise { + const requestUrl = new URL(request.url); + if (requestUrl.host !== DESKTOP_HOST) { + return new Response(null, { status: 404 }); + } -function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { - try { - const url = new URL(requestUrl); - return environment.path.extname(url.pathname).length > 0; - } catch { - return false; + const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin); + const init: RequestInit = { + method: request.method, + headers: request.headers, + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + (init as RequestInit & { duplex: "half" }).duplex = "half"; } + const response = await Electron.net.fetch(targetUrl.toString(), init); + return withContentSecurityPolicy(response, contentSecurityPolicy); } -const make = Effect.gen(function* () { - const registeredProtocols = yield* Ref.make>(new Set()); +export const make = Effect.gen(function* () { + const registered = yield* Ref.make(false); - const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")( - function* ({ - scheme, - handler, - onFailure, - }: { - readonly scheme: string; - readonly handler: ( - request: Electron.ProtocolRequest, - ) => Effect.Effect; - readonly onFailure?: ( - request: Electron.ProtocolRequest, - cause: Cause.Cause, - ) => Electron.ProtocolResponse; - }): Effect.fn.Return { - yield* Effect.annotateCurrentSpan({ scheme }); - const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( - Effect.map((protocols) => protocols.has(scheme)), - ); - if (alreadyRegistered) { - return; - } + const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( + function* (input: DesktopProtocolRegistrationInput) { + if (yield* Ref.get(registered)) return; - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); + const contentSecurityPolicy = makeDesktopContentSecurityPolicy(input); yield* Effect.acquireRelease( Effect.try({ try: () => { - const registered = Electron.protocol.registerFileProtocol( - scheme, - (request, callback) => { - const response = handler(request).pipe( - Effect.withSpan("desktop.electron.protocol.handleFileRequest"), - Effect.catchCause((cause) => - Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)), - ), - ); - - void runPromise(response).then(callback, () => callback({ error: -2 })); - }, + Electron.protocol.handle(input.scheme, (request) => + proxyRequest(request, input.targetOrigin, contentSecurityPolicy), ); - if (!registered) { - throw new ElectronProtocolRegistrationError({ - scheme, - cause: "registerFileProtocol returned false", - }); - } }, - catch: (cause) => - cause instanceof ElectronProtocolRegistrationError - ? cause - : new ElectronProtocolRegistrationError({ scheme, cause }), - }).pipe( - Effect.andThen( - Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)), - ), - ), + catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), + }).pipe(Effect.andThen(Ref.set(registered, true))), () => - Effect.sync(() => { - Electron.protocol.unregisterProtocol(scheme); - }).pipe( - Effect.andThen( - Ref.update(registeredProtocols, (protocols) => { - const next = new Set(protocols); - next.delete(scheme); - return next; + Effect.try({ + try: () => Electron.protocol.unhandle(input.scheme), + catch: (cause) => + new ElectronProtocolUnregistrationError({ + scheme: input.scheme, + cause, }), - ), - ), + }).pipe(Effect.andThen(Ref.set(registered, false)), Effect.orDie), ); }, ); - const registerDesktopFileProtocol = Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - if (environment.isDevelopment) return; - - const staticRoot = yield* resolveDesktopStaticDir; - if (Option.isNone(staticRoot)) { - return yield* new ElectronProtocolStaticBundleMissingError(); - } - - const staticRootResolved = environment.path.resolve(staticRoot.value); - const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; - const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); - - yield* registerFileProtocol({ - scheme: DESKTOP_SCHEME, - handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); - const resolvedCandidate = environment.path.resolve(candidate); - const isInRoot = - resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); - const isAssetRequest = isStaticAssetRequest(request.url, environment); - const exists = yield* fileSystem - .exists(resolvedCandidate) - .pipe(Effect.orElseSucceed(() => false)); - - if (!isInRoot || !exists) { - return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); - } - - return { path: resolvedCandidate } as const; - }), - onFailure: () => ({ path: fallbackIndex }), - }); - }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol")); - - return ElectronProtocol.of({ - registerFileProtocol, - registerDesktopFileProtocol, - }); + return ElectronProtocol.of({ registerDesktopProtocol }); }); export const layer = Layer.effect(ElectronProtocol, make); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index c7b46265887..76162c1647a 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,56 +1,69 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; -export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( +const electronSafeStorageErrorFields = { + cause: Schema.Defect(), +}; + +export class ElectronSafeStorageAvailabilityError extends Schema.TaggedErrorClass()( "ElectronSafeStorageAvailabilityError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to check encryption availability."; } } -export class ElectronSafeStorageEncryptError extends Data.TaggedError( +export class ElectronSafeStorageEncryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageEncryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to encrypt a string."; } } -export class ElectronSafeStorageDecryptError extends Data.TaggedError( +export class ElectronSafeStorageDecryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageDecryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to decrypt a string."; } } -export interface ElectronSafeStorageShape { - readonly isEncryptionAvailable: Effect.Effect; - readonly encryptString: ( - value: string, - ) => Effect.Effect; - readonly decryptString: ( - value: Uint8Array, - ) => Effect.Effect; -} +export const ElectronSafeStorageError = Schema.Union([ + ElectronSafeStorageAvailabilityError, + ElectronSafeStorageEncryptError, + ElectronSafeStorageDecryptError, +]); +export type ElectronSafeStorageError = typeof ElectronSafeStorageError.Type; +export const isElectronSafeStorageError = Schema.is(ElectronSafeStorageError); export class ElectronSafeStorage extends Context.Service< ElectronSafeStorage, - ElectronSafeStorageShape + { + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; + } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -const make = ElectronSafeStorage.of({ +export const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0ecce3bf70e..316d3138bfa 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -20,16 +20,15 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { } } -export interface ElectronShellShape { - readonly openExternal: (rawUrl: unknown) => Effect.Effect; - readonly copyText: (text: string) => Effect.Effect; -} - -export class ElectronShell extends Context.Service()( - "@t3tools/desktop/electron/ElectronShell", -) {} +export class ElectronShell extends Context.Service< + ElectronShell, + { + readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly copyText: (text: string) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronShell") {} -const make = ElectronShell.of({ +export const make = ElectronShell.of({ openExternal: (rawUrl) => Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 0ba7482aace..4b81943eff2 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -8,6 +8,7 @@ const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ themeState: { shouldUseDarkColors: true, themeSource: "system", + setSourceError: null as unknown, }, })); @@ -17,6 +18,9 @@ vi.mock("electron", () => ({ return themeState.shouldUseDarkColors; }, set themeSource(value: string) { + if (themeState.setSourceError !== null) { + throw themeState.setSourceError; + } themeState.themeSource = value; }, on: onMock, @@ -32,6 +36,7 @@ describe("ElectronTheme", () => { removeListenerMock.mockClear(); themeState.shouldUseDarkColors = true; themeState.themeSource = "system"; + themeState.setSourceError = null; }); it.effect("scopes native theme update listeners", () => @@ -49,4 +54,21 @@ describe("ElectronTheme", () => { assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); }).pipe(Effect.provide(ElectronTheme.layer)), ); + + it.effect("preserves the requested source and cause when setting the theme fails", () => + Effect.gen(function* () { + const cause = new Error("theme source failed"); + themeState.setSourceError = cause; + const electronTheme = yield* ElectronTheme.ElectronTheme; + + const error = yield* Effect.flip(electronTheme.setSource("dark")); + + assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); + assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); + assert.strictEqual(error.source, "dark"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "dark"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 1e23d228504..ef47e3d0954 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -1,27 +1,43 @@ -import type { DesktopTheme } from "@t3tools/contracts"; +import { DesktopThemeSchema, type DesktopTheme } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; -export interface ElectronThemeShape { - readonly shouldUseDarkColors: Effect.Effect; - readonly setSource: (theme: DesktopTheme) => Effect.Effect; - readonly onUpdated: (listener: () => void) => Effect.Effect; +export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass()( + "ElectronThemeSetSourceError", + { + source: DesktopThemeSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to set the Electron theme source to ${this.source}.`; + } } -export class ElectronTheme extends Context.Service()( - "@t3tools/desktop/electron/ElectronTheme", -) {} +export const isElectronThemeSetSourceError = Schema.is(ElectronThemeSetSourceError); -const make = ElectronTheme.of({ +export class ElectronTheme extends Context.Service< + ElectronTheme, + { + readonly shouldUseDarkColors: Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly onUpdated: (listener: () => void) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTheme") {} + +export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => - Effect.suspend(() => { - Electron.nativeTheme.themeSource = theme; - return Effect.void; + Effect.try({ + try: () => { + Electron.nativeTheme.themeSource = theme; + }, + catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), }), onUpdated: (listener) => Effect.acquireRelease( diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index d2d3edd3696..8fcc34f41c2 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,5 +1,4 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; @@ -65,15 +64,65 @@ describe("ElectronUpdater", () => { const cause = new Error("network unavailable"); autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "beta"; - const exit = yield* Effect.exit(updater.checkForUpdates); + const error = yield* updater.checkForUpdates.pipe(Effect.flip); - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.equal(error.cause, cause); - } + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "beta"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("preserves the execution-time channel on download failures", () => + Effect.gen(function* () { + const cause = new Error("download unavailable"); + autoUpdaterMock.downloadUpdate.mockImplementationOnce(() => Promise.reject(cause)); + const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "nightly"; + + const error = yield* updater.downloadUpdate.pipe(Effect.flip); + + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "nightly"); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Electron updater failed to download the update on channel nightly.", + ); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("preserves quit-and-install flags and the execution-time channel", () => + Effect.gen(function* () { + const cause = new Error("quit and install failed"); + autoUpdaterMock.quitAndInstall.mockImplementationOnce(() => { + throw cause; + }); + const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "alpha"; + + const error = yield* updater + .quitAndInstall({ isSilent: true, isForceRunAfter: false }) + .pipe(Effect.flip); + + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "alpha"); + assert.equal(error.isSilent, true); + assert.equal(error.isForceRunAfter, false); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Electron updater failed to quit and install the update on channel alpha (silent: true, force run after: false).", + ); + assert.notInclude(error.message, cause.message); + assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); }).pipe(Effect.provide(ElectronUpdater.layer)), ); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 7f3edf02aa8..435fbd00228 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { autoUpdater } from "electron-updater"; @@ -10,67 +10,77 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( +export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to check for updates."; + { + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron updater failed to check for updates on channel ${this.channel ?? "default"}.`; } } -export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( +export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( "ElectronUpdaterDownloadUpdateError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to download the update."; + { + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron updater failed to download the update on channel ${this.channel ?? "default"}.`; } } -export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( +export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( "ElectronUpdaterQuitAndInstallError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to quit and install the update."; + { + channel: Schema.NullOr(Schema.String), + isSilent: Schema.Boolean, + isForceRunAfter: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron updater failed to quit and install the update on channel ${this.channel ?? "default"} (silent: ${this.isSilent}, force run after: ${this.isForceRunAfter}).`; } } -export type ElectronUpdaterError = - | ElectronUpdaterCheckForUpdatesError - | ElectronUpdaterDownloadUpdateError - | ElectronUpdaterQuitAndInstallError; - -export interface ElectronUpdaterShape { - readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; - readonly setAutoDownload: (value: boolean) => Effect.Effect; - readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; - readonly setChannel: (channel: string) => Effect.Effect; - readonly setAllowPrerelease: (value: boolean) => Effect.Effect; - readonly allowDowngrade: Effect.Effect; - readonly setAllowDowngrade: (value: boolean) => Effect.Effect; - readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly checkForUpdates: Effect.Effect; - readonly downloadUpdate: Effect.Effect; - readonly quitAndInstall: (options: { - readonly isSilent: boolean; - readonly isForceRunAfter: boolean; - }) => Effect.Effect; - readonly on: >( - eventName: string, - listener: (...args: Args) => void, - ) => Effect.Effect; -} +export const ElectronUpdaterError = Schema.Union([ + ElectronUpdaterCheckForUpdatesError, + ElectronUpdaterDownloadUpdateError, + ElectronUpdaterQuitAndInstallError, +]); +export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; +export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); -export class ElectronUpdater extends Context.Service()( - "@t3tools/desktop/electron/ElectronUpdater", -) {} +export class ElectronUpdater extends Context.Service< + ElectronUpdater, + { + readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; + readonly setAutoDownload: (value: boolean) => Effect.Effect; + readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; + readonly setChannel: (channel: string) => Effect.Effect; + readonly setAllowPrerelease: (value: boolean) => Effect.Effect; + readonly allowDowngrade: Effect.Effect; + readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; + readonly quitAndInstall: (options: { + readonly isSilent: boolean; + readonly isForceRunAfter: boolean; + }) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronUpdater") {} -export const layer = Layer.succeed(ElectronUpdater, { +export const make = ElectronUpdater.of({ setFeedURL: (options) => Effect.suspend(() => { autoUpdater.setFeedURL(options); @@ -107,18 +117,33 @@ export const layer = Layer.succeed(ElectronUpdater, { autoUpdater.disableDifferentialDownload = value; return Effect.void; }), - checkForUpdates: Effect.tryPromise({ - try: () => autoUpdater.checkForUpdates(), - catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), - }).pipe(Effect.asVoid), - downloadUpdate: Effect.tryPromise({ - try: () => autoUpdater.downloadUpdate(), - catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), - }).pipe(Effect.asVoid), + checkForUpdates: Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.tryPromise({ + try: () => autoUpdater.checkForUpdates(), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), + }).pipe(Effect.asVoid); + }), + downloadUpdate: Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.tryPromise({ + try: () => autoUpdater.downloadUpdate(), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), + }).pipe(Effect.asVoid); + }), quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.try({ - try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), - catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), + Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.try({ + try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), + catch: (cause) => + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause, + }), + }); }), on: (eventName, listener) => { const eventTarget = autoUpdater as unknown as { @@ -136,4 +161,6 @@ export const layer = Layer.succeed(ElectronUpdater, { }), ).pipe(Effect.asVoid); }, -} satisfies ElectronUpdaterShape); +}); + +export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index cc6c6484245..b59f8572739 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -1,26 +1,39 @@ import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; -const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ - appFocusMock: vi.fn(), - getAllWindowsMock: vi.fn(), -})); +const { appFocusMock, browserWindowMock, getAllWindowsMock, getFocusedWindowMock } = vi.hoisted( + () => ({ + appFocusMock: vi.fn(), + browserWindowMock: vi.fn(function BrowserWindowMock() {}), + getAllWindowsMock: vi.fn(), + getFocusedWindowMock: vi.fn(), + }), +); vi.mock("electron", () => ({ app: { focus: appFocusMock, }, - BrowserWindow: { + BrowserWindow: Object.assign(browserWindowMock, { getAllWindows: getAllWindowsMock, - }, + getFocusedWindow: getFocusedWindowMock, + }), })); import * as ElectronWindow from "./ElectronWindow.ts"; -function makeBrowserWindow(input: { readonly destroyed: boolean }) { +const TestLayer = ElectronWindow.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + +function makeBrowserWindow(input: { readonly id: number; readonly destroyed: boolean }) { return { + id: input.id, isDestroyed: vi.fn(() => input.destroyed), } as unknown as Electron.BrowserWindow; } @@ -28,13 +41,78 @@ function makeBrowserWindow(input: { readonly destroyed: boolean }) { describe("ElectronWindow", () => { beforeEach(() => { appFocusMock.mockReset(); + browserWindowMock.mockReset(); getAllWindowsMock.mockReset(); + getFocusedWindowMock.mockReset(); }); + it.effect("preserves schema-safe creation context and the Electron cause", () => + Effect.gen(function* () { + const cause = new Error("native BrowserWindow construction failed"); + browserWindowMock.mockImplementationOnce(function BrowserWindowFailure() { + throw cause; + }); + const options = { + title: "T3 Code", + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + modal: false, + frame: true, + transparent: false, + backgroundColor: "#101010", + icon: {} as Electron.NativeImage, + webPreferences: { + preload: "/tmp/preload.js", + partition: "persist:t3code-preview-test", + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webviewTag: true, + spellcheck: true, + }, + } satisfies Electron.BrowserWindowConstructorOptions; + const electronWindow = yield* ElectronWindow.ElectronWindow; + + const error = yield* electronWindow.create(options).pipe(Effect.flip); + + assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); + assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); + assert.deepEqual(error.options, { + title: "T3 Code", + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + modal: false, + frame: true, + transparent: false, + backgroundColor: "#101010", + webPreferences: { + preload: "/tmp/preload.js", + partition: "persist:t3code-preview-test", + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webviewTag: true, + }, + }); + assert.isFalse("icon" in error.options); + assert.isFalse("spellcheck" in error.options.webPreferences); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to create Electron BrowserWindow "T3 Code" (1100x780).'); + assert.notInclude(error.message, cause.message); + assert.deepEqual(browserWindowMock.mock.calls, [[options]]); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("skips windows destroyed before appearance sync runs", () => Effect.gen(function* () { - const liveWindow = makeBrowserWindow({ destroyed: false }); - const destroyedWindow = makeBrowserWindow({ destroyed: true }); + const liveWindow = makeBrowserWindow({ id: 1, destroyed: false }); + const destroyedWindow = makeBrowserWindow({ id: 2, destroyed: true }); getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); const syncedWindows: Electron.BrowserWindow[] = []; @@ -46,6 +124,112 @@ describe("ElectronWindow", () => { ); assert.deepEqual(syncedWindows, [liveWindow]); - }).pipe(Effect.provide(ElectronWindow.layer)), + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves window enumeration failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("window enumeration failed"); + getAllWindowsMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.currentMainOrFirst); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "list-windows"); + assert.equal(error.platform, "linux"); + assert.isNull(error.windowId); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves reveal failures with the target window", () => + Effect.gen(function* () { + const cause = new Error("window restore failed"); + const window = { + id: 41, + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => true), + restore: vi.fn(() => { + throw cause; + }), + } as unknown as Electron.BrowserWindow; + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.reveal(window)); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "reveal-window"); + assert.equal(error.windowId, 41); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves message delivery failures with window and channel context", () => + Effect.gen(function* () { + const cause = new Error("renderer send failed"); + const window = { + id: 42, + isDestroyed: vi.fn(() => false), + webContents: { + send: vi.fn(() => { + throw cause; + }), + }, + } as unknown as Electron.BrowserWindow; + getAllWindowsMock.mockReturnValueOnce([window]); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.sendAll("desktop:update", { ready: true })); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "send-window-message"); + assert.equal(error.windowId, 42); + assert.equal(error.channel, "desktop:update"); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves destroy failures with the target window", () => + Effect.gen(function* () { + const cause = new Error("window destroy failed"); + const window = { + id: 43, + destroy: vi.fn(() => { + throw cause; + }), + } as unknown as Electron.BrowserWindow; + getAllWindowsMock.mockReturnValueOnce([window]); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.destroyAll); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "destroy-window"); + assert.equal(error.windowId, 43); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 35c1fbc5faa..dacb2eebb47 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -1,49 +1,135 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ - readonly cause: unknown; -}> { - override get message() { - return "Failed to create Electron BrowserWindow."; +const ElectronWindowCreateOptions = Schema.Struct({ + title: Schema.NullOr(Schema.String), + width: Schema.NullOr(Schema.Number), + height: Schema.NullOr(Schema.Number), + minWidth: Schema.NullOr(Schema.Number), + minHeight: Schema.NullOr(Schema.Number), + show: Schema.NullOr(Schema.Boolean), + modal: Schema.NullOr(Schema.Boolean), + frame: Schema.NullOr(Schema.Boolean), + transparent: Schema.NullOr(Schema.Boolean), + backgroundColor: Schema.NullOr(Schema.String), + webPreferences: Schema.Struct({ + preload: Schema.NullOr(Schema.String), + partition: Schema.NullOr(Schema.String), + sandbox: Schema.NullOr(Schema.Boolean), + contextIsolation: Schema.NullOr(Schema.Boolean), + nodeIntegration: Schema.NullOr(Schema.Boolean), + webviewTag: Schema.NullOr(Schema.Boolean), + }), +}); + +const ElectronWindowOperation = Schema.Literals([ + "list-windows", + "get-focused-window", + "inspect-window", + "reveal-window", + "send-window-message", + "destroy-window", +]); + +export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( + "ElectronWindowCreateError", + { + options: ElectronWindowCreateOptions, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const title = this.options.title === null ? "" : ` "${this.options.title}"`; + const dimensions = + this.options.width === null || this.options.height === null + ? "" + : ` (${this.options.width}x${this.options.height})`; + return `Failed to create Electron BrowserWindow${title}${dimensions}.`; } } -export interface ElectronWindowShape { - readonly create: ( - options: Electron.BrowserWindowConstructorOptions, - ) => Effect.Effect; - readonly main: Effect.Effect>; - readonly currentMainOrFirst: Effect.Effect>; - readonly focusedMainOrFirst: Effect.Effect>; - readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; - readonly clearMain: (window: Option.Option) => Effect.Effect; - readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; - readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; - readonly destroyAll: Effect.Effect; - readonly syncAllAppearance: ( - sync: (window: Electron.BrowserWindow) => Effect.Effect, - ) => Effect.Effect; +export const isElectronWindowCreateError = Schema.is(ElectronWindowCreateError); + +export class ElectronWindowOperationError extends Schema.TaggedErrorClass()( + "ElectronWindowOperationError", + { + operation: ElectronWindowOperation, + platform: Schema.String, + windowId: Schema.NullOr(Schema.Number), + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const window = this.windowId === null ? "" : ` for window ${this.windowId}`; + const channel = this.channel === null ? "" : ` on channel ${JSON.stringify(this.channel)}`; + return `Electron window operation ${JSON.stringify(this.operation)} failed${window}${channel} on ${this.platform}.`; + } } -export class ElectronWindow extends Context.Service()( - "@t3tools/desktop/electron/ElectronWindow", -) {} +export class ElectronWindow extends Context.Service< + ElectronWindow, + { + readonly create: ( + options: Electron.BrowserWindowConstructorOptions, + ) => Effect.Effect; + readonly main: Effect.Effect>; + readonly currentMainOrFirst: Effect.Effect>; + readonly focusedMainOrFirst: Effect.Effect>; + readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; + readonly clearMain: (window: Option.Option) => Effect.Effect; + readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; + readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; + readonly destroyAll: Effect.Effect; + readonly syncAllAppearance: ( + sync: (window: Electron.BrowserWindow) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronWindow") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; const mainWindowRef = yield* Ref.make>(Option.none()); - const liveMain = Ref.get(mainWindowRef).pipe( - Effect.map(Option.filter((value) => !value.isDestroyed())), - ); + const listWindows = Effect.try({ + try: () => Electron.BrowserWindow.getAllWindows(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "list-windows", + platform, + windowId: null, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + + const isWindowDestroyed = (window: Electron.BrowserWindow) => + Effect.try({ + try: () => window.isDestroyed(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "inspect-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + + const liveMain = Effect.gen(function* () { + const main = yield* Ref.get(mainWindowRef); + if (Option.isNone(main) || (yield* isWindowDestroyed(main.value))) { + return Option.none(); + } + return main; + }); const currentMainOrFirst = Effect.gen(function* () { const main = yield* liveMain; @@ -51,27 +137,60 @@ const make = Effect.gen(function* () { return main; } - return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( - Option.filter((window) => !window.isDestroyed()), - ); + const first = Option.fromNullishOr((yield* listWindows)[0] ?? null); + if (Option.isNone(first) || (yield* isWindowDestroyed(first.value))) { + return Option.none(); + } + return first; }); - const focusedMainOrFirst = Effect.sync(() => - Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( - Option.filter((window) => !window.isDestroyed()), - ), - ).pipe( - Effect.flatMap((focused) => - Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst, - ), - ); + const focusedMainOrFirst = Effect.gen(function* () { + const focused = yield* Effect.try({ + try: () => Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "get-focused-window", + platform, + windowId: null, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + if (Option.isSome(focused) && !(yield* isWindowDestroyed(focused.value))) { + return focused; + } + return yield* currentMainOrFirst; + }); return ElectronWindow.of({ - create: (options) => - Effect.try({ + create: (options) => { + const webPreferences = options.webPreferences; + const diagnosticOptions = { + title: options.title ?? null, + width: options.width ?? null, + height: options.height ?? null, + minWidth: options.minWidth ?? null, + minHeight: options.minHeight ?? null, + show: options.show ?? null, + modal: options.modal ?? null, + frame: options.frame ?? null, + transparent: options.transparent ?? null, + backgroundColor: options.backgroundColor ?? null, + webPreferences: { + preload: webPreferences?.preload ?? null, + partition: webPreferences?.partition ?? null, + sandbox: webPreferences?.sandbox ?? null, + contextIsolation: webPreferences?.contextIsolation ?? null, + nodeIntegration: webPreferences?.nodeIntegration ?? null, + webviewTag: webPreferences?.webviewTag ?? null, + }, + } satisfies typeof ElectronWindowCreateOptions.Type; + + return Effect.try({ try: () => new Electron.BrowserWindow(options), - catch: (cause) => new ElectronWindowCreateError({ cause }), - }), + catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), + }); + }, main: liveMain, currentMainOrFirst, focusedMainOrFirst, @@ -87,45 +206,75 @@ const make = Effect.gen(function* () { return Option.none(); }), reveal: (window) => - Effect.sync(() => { - if (window.isDestroyed()) { - return; - } + Effect.try({ + try: () => { + if (window.isDestroyed()) { + return; + } - if (window.isMinimized()) { - window.restore(); - } + if (window.isMinimized()) { + window.restore(); + } - if (!window.isVisible()) { - window.show(); - } + if (!window.isVisible()) { + window.show(); + } - if (platform === "darwin") { - Electron.app.focus({ steal: true }); - } + if (platform === "darwin") { + Electron.app.focus({ steal: true }); + } - window.focus(); - }), + window.focus(); + }, + catch: (cause) => + new ElectronWindowOperationError({ + operation: "reveal-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie), sendAll: (channel, ...args) => - Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { - if (window.isDestroyed()) { + Effect.gen(function* () { + for (const window of yield* listWindows) { + if (yield* isWindowDestroyed(window)) { continue; } - window.webContents.send(channel, ...args); + yield* Effect.try({ + try: () => window.webContents.send(channel, ...args), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "send-window-message", + platform, + windowId: window.id, + channel, + cause, + }), + }).pipe(Effect.orDie); } }), - destroyAll: Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { - window.destroy(); + destroyAll: Effect.gen(function* () { + for (const window of yield* listWindows) { + yield* Effect.try({ + try: () => window.destroy(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "destroy-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie); } }), syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( sync: (window: Electron.BrowserWindow) => Effect.Effect, ) { - const windows = Electron.BrowserWindow.getAllWindows(); + const windows = yield* listWindows; for (const window of windows) { - if (window.isDestroyed()) { + if (yield* isWindowDestroyed(window)) { continue; } yield* sync(window); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts new file mode 100644 index 00000000000..fc311877f82 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +import * as DesktopIpc from "./DesktopIpc.ts"; + +const invokeMethod: DesktopIpc.DesktopIpcMethod = { + channel: "desktop.test.invoke", + handler: () => Effect.void, +}; + +const syncMethod: DesktopIpc.DesktopSyncIpcMethod = { + channel: "desktop.test.sync", + handler: () => Effect.void, +}; + +function makeIpcMain( + overrides: Partial = {}, +): DesktopIpc.DesktopIpcMain { + return { + removeHandler: vi.fn(), + handle: vi.fn(), + removeAllListeners: vi.fn(), + on: vi.fn(), + ...overrides, + }; +} + +describe("DesktopIpc", () => { + it.effect("preserves invoke registration context and cause", () => + Effect.gen(function* () { + const cause = new Error("invoke registration failed"); + const ipcMain = makeIpcMain({ + handle: () => { + throw cause; + }, + }); + const ipc = DesktopIpc.make(ipcMain); + + const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); + + assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); + assert.isTrue(DesktopIpc.isDesktopIpcError(error)); + assert.strictEqual(error.handlerKind, "invoke"); + assert.strictEqual(error.channel, invokeMethod.channel); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "invoke"); + assert.include(error.message, invokeMethod.channel); + assert.notInclude(error.message, cause.message); + }), + ); + + it.effect("preserves sync unregistration context and cause in the finalizer defect", () => + Effect.gen(function* () { + const cause = new Error("sync unregistration failed"); + let removeCount = 0; + const ipcMain = makeIpcMain({ + removeAllListeners: () => { + removeCount += 1; + if (removeCount === 2) throw cause; + }, + }); + const ipc = DesktopIpc.make(ipcMain); + + const exit = yield* Effect.exit(Effect.scoped(ipc.handleSync(syncMethod))); + + assert.isTrue(exit._tag === "Failure"); + if (exit._tag === "Success") return; + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); + assert.isTrue(DesktopIpc.isDesktopIpcError(error)); + assert.strictEqual(error.handlerKind, "sync"); + assert.strictEqual(error.channel, syncMethod.channel); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + }), + ); +}); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 6d954a97aec..e948571cc62 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -23,6 +24,39 @@ export interface DesktopIpcMain { on(channel: string, listener: DesktopIpcSyncListener): void; } +export class DesktopIpcRegistrationError extends Schema.TaggedErrorClass()( + "DesktopIpcRegistrationError", + { + handlerKind: Schema.Literals(["invoke", "sync"]), + channel: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to register the ${this.handlerKind} IPC handler for ${this.channel}.`; + } +} + +export class DesktopIpcUnregistrationError extends Schema.TaggedErrorClass()( + "DesktopIpcUnregistrationError", + { + handlerKind: Schema.Literals(["invoke", "sync"]), + channel: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to unregister the ${this.handlerKind} IPC handler for ${this.channel}.`; + } +} + +export const DesktopIpcError = Schema.Union([ + DesktopIpcRegistrationError, + DesktopIpcUnregistrationError, +]); +export type DesktopIpcError = typeof DesktopIpcError.Type; +export const isDesktopIpcError = Schema.is(DesktopIpcError); + export interface DesktopIpcMethod { readonly channel: string; readonly handler: (raw: unknown) => Effect.Effect; @@ -33,20 +67,19 @@ export interface DesktopSyncIpcMethod { readonly handler: () => Effect.Effect; } -export interface DesktopIpcShape { - readonly handle: ( - input: DesktopIpcMethod, - ) => Effect.Effect; - readonly handleSync: ( - input: DesktopSyncIpcMethod, - ) => Effect.Effect; -} - -export class DesktopIpc extends Context.Service()( - "@t3tools/desktop/ipc/DesktopIpc", -) {} +export class DesktopIpc extends Context.Service< + DesktopIpc, + { + readonly handle: ( + input: DesktopIpcMethod, + ) => Effect.Effect; + readonly handleSync: ( + input: DesktopSyncIpcMethod, + ) => Effect.Effect; + } +>()("@t3tools/desktop/ipc/DesktopIpc") {} -export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => +export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => DesktopIpc.of({ handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ channel, @@ -57,18 +90,27 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => const runPromise = Effect.runPromiseWith(context); yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeHandler(channel); - ipcMain.handle(channel, (_event, raw) => - runPromise( - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ channel }); - return yield* handler(raw); - }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), - ), - ); + Effect.try({ + try: () => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, (_event, raw) => + runPromise( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(raw); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), + ), + ); + }, + catch: (cause) => + new DesktopIpcRegistrationError({ handlerKind: "invoke", channel, cause }), }), - () => Effect.sync(() => ipcMain.removeHandler(channel)), + () => + Effect.try({ + try: () => ipcMain.removeHandler(channel), + catch: (cause) => + new DesktopIpcUnregistrationError({ handlerKind: "invoke", channel, cause }), + }).pipe(Effect.orDie), ); }), @@ -81,22 +123,36 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => const runSync = Effect.runSyncWith(context); yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeAllListeners(channel); - ipcMain.on(channel, (event) => { - event.returnValue = runSync( - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ channel }); - return yield* handler(); - }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")), - ); - }); + Effect.try({ + try: () => { + ipcMain.removeAllListeners(channel); + ipcMain.on(channel, (event) => { + event.returnValue = runSync( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(); + }).pipe( + Effect.annotateLogs({ channel }), + Effect.withSpan("desktop.ipc.invokeSync"), + ), + ); + }); + }, + catch: (cause) => + new DesktopIpcRegistrationError({ handlerKind: "sync", channel, cause }), }), - () => Effect.sync(() => ipcMain.removeAllListeners(channel)), + () => + Effect.try({ + try: () => ipcMain.removeAllListeners(channel), + catch: (cause) => + new DesktopIpcUnregistrationError({ handlerKind: "sync", channel, cause }), + }).pipe(Effect.orDie), ); }), }); +export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain)); + /** * Convenience helpers for creating IPC methods */ diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a6c8428efa9..180e44e52d9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,21 +1,12 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; -import { - clearCloudAuthToken, - createCloudAuthRequest, - fetchCloudAuth, - getCloudAuthToken, - setCloudAuthToken, -} from "./methods/cloudAuth.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { - getSavedEnvironmentRegistry, - getSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - setSavedEnvironmentRegistry, - setSavedEnvironmentSecret, -} from "./methods/savedEnvironments.ts"; + clearConnectionCatalog, + getConnectionCatalog, + setConnectionCatalog, +} from "./methods/connectionCatalog.ts"; import { getAdvertisedEndpoints, getServerExposureState, @@ -42,6 +33,7 @@ import { import { confirm, getAppBranding, + getLocalEnvironmentBearerToken, getLocalEnvironmentBootstrap, openExternal, pickFolder, @@ -56,14 +48,13 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); + yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); - yield* ipc.handle(getSavedEnvironmentRegistry); - yield* ipc.handle(setSavedEnvironmentRegistry); - yield* ipc.handle(getSavedEnvironmentSecret); - yield* ipc.handle(setSavedEnvironmentSecret); - yield* ipc.handle(removeSavedEnvironmentSecret); + yield* ipc.handle(getConnectionCatalog); + yield* ipc.handle(setConnectionCatalog); + yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(ensureSshEnvironment); @@ -84,11 +75,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); - yield* ipc.handle(createCloudAuthRequest); - yield* ipc.handle(getCloudAuthToken); - yield* ipc.handle(setCloudAuthToken); - yield* ipc.handle(clearCloudAuthToken); - yield* ipc.handle(fetchCloudAuth); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c5dabe0930f..cc2a92ca8fd 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,12 +3,6 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -export const CREATE_CLOUD_AUTH_REQUEST_CHANNEL = "desktop:create-cloud-auth-request"; -export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token"; -export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token"; -export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token"; -export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth"; -export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; @@ -18,13 +12,13 @@ export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = + "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; -export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; -export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; -export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; -export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; -export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; +export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; +export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index 52b173266cd..dd0625759e9 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -5,9 +5,9 @@ import * as Schema from "effect/Schema"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; -export const getClientSettings = makeIpcMethod({ +export const getClientSettings = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL, payload: Schema.Void, result: Schema.NullOr(ClientSettingsSchema), @@ -17,7 +17,7 @@ export const getClientSettings = makeIpcMethod({ }), }); -export const setClientSettings = makeIpcMethod({ +export const setClientSettings = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, payload: ClientSettingsSchema, result: Schema.Void, diff --git a/apps/desktop/src/ipc/methods/cloudAuth.test.ts b/apps/desktop/src/ipc/methods/cloudAuth.test.ts deleted file mode 100644 index c5f1e2b2c90..00000000000 --- a/apps/desktop/src/ipc/methods/cloudAuth.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { afterEach } from "vite-plus/test"; - -import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts"; - -const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY; -const originalFetch = globalThis.fetch; - -const clerkPublishableKey = (hostname: string): string => - `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`; - -type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; - -const recordedFetch = (...responses: ReadonlyArray) => { - const calls: Array = []; - let responseIndex = 0; - const fetchFn = ((input, init) => { - calls.push([input, init ?? {}]); - const response = responses[responseIndex++]; - if (!response) { - return Promise.reject(new Error("Unexpected fetch call")); - } - return Promise.resolve(response); - }) satisfies typeof fetch; - - return { fetchFn, calls }; -}; - -describe("Desktop cloud auth IPC", () => { - afterEach(() => { - globalThis.fetch = originalFetch; - if (originalClerkPublishableKey === undefined) { - delete process.env.T3CODE_CLERK_PUBLISHABLE_KEY; - } else { - process.env.T3CODE_CLERK_PUBLISHABLE_KEY = originalClerkPublishableKey; - } - }); - - it.effect("preserves Clerk's URL-encoded OAuth form content type", () => { - const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback"; - const fetch = recordedFetch(Response.json({ response: { object: "sign_in_attempt" } })); - globalThis.fetch = fetch.fetchFn; - - return Effect.gen(function* () { - yield* fetchCloudAuth.handler({ - url: "https://example.clerk.accounts.dev/v1/client/sign_ins", - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - "x-mobile": "1", - }, - body, - }); - - const forwardedRequest = fetch.calls[0]; - assert(forwardedRequest !== undefined); - const [url, init] = forwardedRequest; - assert.equal(String(url), "https://example.clerk.accounts.dev/v1/client/sign_ins"); - assert.equal(init.method, "POST"); - assert.equal( - new Headers(init.headers).get("content-type"), - "application/x-www-form-urlencoded;charset=UTF-8", - ); - assert.equal(new TextDecoder().decode(init.body as Uint8Array), body); - }); - }); - - it.effect( - "allows the custom Clerk Frontend API host encoded by the configured publishable key", - () => { - process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); - const fetch = recordedFetch(Response.json({ response: { object: "client" } })); - globalThis.fetch = fetch.fetchFn; - - return Effect.gen(function* () { - yield* fetchCloudAuth.handler({ - url: "https://clerk.t3.codes/v1/client", - method: "GET", - headers: {}, - }); - - const forwardedRequest = fetch.calls[0]; - assert(forwardedRequest !== undefined); - assert.equal(String(forwardedRequest[0]), "https://clerk.t3.codes/v1/client"); - }); - }, - ); - - it("rejects arbitrary HTTPS hosts that are not configured Clerk Frontend API hosts", () => { - process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); - assert.throws( - () => validateClerkFrontendApiUrl("https://attacker.example/v1/client"), - /restricted to Clerk Frontend API HTTPS hosts/u, - ); - }); -}); diff --git a/apps/desktop/src/ipc/methods/cloudAuth.ts b/apps/desktop/src/ipc/methods/cloudAuth.ts deleted file mode 100644 index a5a7aacff79..00000000000 --- a/apps/desktop/src/ipc/methods/cloudAuth.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - DesktopCloudAuthFetchInputSchema, - DesktopCloudAuthFetchResultSchema, -} from "@t3tools/contracts"; -import { - clerkFrontendApiHostnameFromPublishableKey, - isAllowedClerkFrontendApiHostname, -} from "@t3tools/shared/relayAuth"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import { identity } from "effect/Function"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; - -import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; -import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts"; -import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; - -declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; - -export class DesktopCloudAuthFetchError extends Data.TaggedError("DesktopCloudAuthFetchError")<{ - readonly reason: string; - readonly cause?: unknown; -}> { - override get message() { - return this.reason; - } -} - -function configuredClerkFrontendApiHostname(): string | null { - const publishableKey = - process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || - (typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" - ? "" - : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__.trim()); - if (!publishableKey) return null; - - return clerkFrontendApiHostnameFromPublishableKey(publishableKey); -} - -const allowedClerkFrontendApiHosts = (hostname: string): boolean => - isAllowedClerkFrontendApiHostname(hostname, configuredClerkFrontendApiHostname()); - -export function validateClerkFrontendApiUrl(rawUrl: string): URL { - const url = new URL(rawUrl); - if (url.protocol !== "https:" || !allowedClerkFrontendApiHosts(url.hostname)) { - throw new DesktopCloudAuthFetchError({ - reason: "Desktop cloud auth fetch is restricted to Clerk Frontend API HTTPS hosts.", - }); - } - return url; -} - -function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInputSchema.Type) { - return Effect.gen(function* () { - const method = (input.method ?? "GET") as "GET" | "POST"; - const headers = new Headers(input.headers); - const response = yield* HttpClientRequest.make(method)(url).pipe( - HttpClientRequest.setHeaders(headers), - input.body === undefined - ? identity - : HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined), - HttpClient.execute, - Effect.mapError( - (cause) => - new DesktopCloudAuthFetchError({ - reason: "Desktop cloud auth fetch failed to execute.", - cause, - }), - ), - ); - - const body = yield* response.text.pipe( - Effect.mapError( - (cause) => - new DesktopCloudAuthFetchError({ - reason: "Desktop cloud auth fetch response could not be read.", - cause, - }), - ), - ); - - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: "", - headers: response.headers, - body, - }; - }); -} - -const electronNetFetchLayer = Layer.unwrap( - Effect.gen(function* () { - const electronFetch = yield* Effect.promise(async () => { - const electron = (await import("electron")) as { - readonly net?: { readonly fetch?: typeof globalThis.fetch }; - }; - return typeof electron.net?.fetch === "function" - ? electron.net.fetch.bind(electron.net) - : null; - }).pipe(Effect.catchCause(() => Effect.succeed(null))); - - if (!electronFetch) { - yield* Effect.logWarning( - "electron.net.fetch is not available, falling back to global fetch. This may cause unexpected errors.", - ); - } - - return FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.Fetch, electronFetch ?? globalThis.fetch)), - ); - }), -); - -export const createCloudAuthRequest = makeIpcMethod({ - channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL, - payload: Schema.Void, - result: Schema.String, - handler: Effect.fn("desktop.ipc.cloudAuth.createRequest")(function* () { - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; - return yield* cloudAuth.createRequest; - }), -}); - -export const getCloudAuthToken = makeIpcMethod({ - channel: IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL, - payload: Schema.Void, - result: Schema.NullOr(Schema.String), - handler: Effect.fn("desktop.ipc.cloudAuth.getToken")(function* () { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - return Option.getOrNull(yield* tokenStore.get); - }), -}); - -export const setCloudAuthToken = makeIpcMethod({ - channel: IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, - payload: Schema.String, - result: Schema.Boolean, - handler: Effect.fn("desktop.ipc.cloudAuth.setToken")(function* (token) { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - return yield* tokenStore.set(token); - }), -}); - -export const clearCloudAuthToken = makeIpcMethod({ - channel: IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL, - payload: Schema.Void, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.cloudAuth.clearToken")(function* () { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - yield* tokenStore.clear; - }), -}); - -export const fetchCloudAuth = makeIpcMethod({ - channel: IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, - payload: DesktopCloudAuthFetchInputSchema, - result: DesktopCloudAuthFetchResultSchema, - handler: Effect.fn("desktop.ipc.cloudAuth.fetch")(function* (input) { - const url = yield* Effect.try({ - try: () => validateClerkFrontendApiUrl(input.url), - catch: (cause) => - cause instanceof DesktopCloudAuthFetchError - ? cause - : new DesktopCloudAuthFetchError({ - reason: "Desktop cloud auth fetch received an invalid URL.", - cause, - }), - }); - - return yield* executeCloudAuthFetch(url, input).pipe(Effect.provide(electronNetFetchLayer)); - }), -}); diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts new file mode 100644 index 00000000000..4e51496a637 --- /dev/null +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopConnectionCatalogStore from "../../app/DesktopConnectionCatalogStore.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const getConnectionCatalog = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_CONNECTION_CATALOG_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.connectionCatalog.get")(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + return Option.getOrNull(yield* store.get); + }), +}); + +export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.connectionCatalog.set")(function* (catalog) { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + return yield* store.set(catalog); + }), +}); + +export const clearConnectionCatalog = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + yield* store.clear; + }), +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 8adae374ad0..2abf53ac284 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -19,40 +19,33 @@ import { PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; -import { BrowserWindow } from "electron"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { pathToFileURL } from "node:url"; +import * as NodeURL from "node:url"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; - -const broadcast = (channel: string, ...args: ReadonlyArray): void => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send(channel, ...args); - } - } -}; +import * as DesktopIpc from "../DesktopIpc.ts"; export const installPreviewEventForwarding = Effect.fn( "desktop.ipc.preview.installEventForwarding", )(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; const manager = yield* PreviewManager.PreviewManager; - yield* manager.subscribeStateChanges((tabId, state) => { - broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); - }); - yield* manager.subscribeRecordingFrames((frame) => { - broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); - }); - yield* manager.subscribePointerEvents((event) => { - broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); - }); + yield* manager.subscribeStateChanges((tabId, state) => + electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), + ); + yield* manager.subscribeRecordingFrames((frame) => + electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), + ); + yield* manager.subscribePointerEvents((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), + ); }); -export const createTab = makeIpcMethod({ +export const createTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.Void, @@ -62,7 +55,7 @@ export const createTab = makeIpcMethod({ }), }); -export const closeTab = makeIpcMethod({ +export const closeTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.Void, @@ -72,7 +65,7 @@ export const closeTab = makeIpcMethod({ }), }); -export const registerWebview = makeIpcMethod({ +export const registerWebview = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, payload: DesktopPreviewRegisterWebviewInputSchema, result: Schema.Void, @@ -82,7 +75,7 @@ export const registerWebview = makeIpcMethod({ }), }); -export const navigate = makeIpcMethod({ +export const navigate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, payload: DesktopPreviewNavigateInputSchema, result: Schema.Void, @@ -96,11 +89,11 @@ const tabMethod = ( channel: string, name: string, invoke: ( - manager: PreviewManager.PreviewManagerShape, + manager: PreviewManager.PreviewManager["Service"], tabId: string, ) => Effect.Effect, ) => - makeIpcMethod({ + DesktopIpc.makeIpcMethod({ channel, payload: DesktopPreviewTabInputSchema, result: Schema.Void, @@ -166,7 +159,7 @@ export const stopRecording = tabMethod( (manager, tabId) => manager.stopRecording(tabId), ); -export const clearCookies = makeIpcMethod({ +export const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, payload: Schema.Void, result: Schema.Void, @@ -176,7 +169,7 @@ export const clearCookies = makeIpcMethod({ }), }); -export const clearCache = makeIpcMethod({ +export const clearCache = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, payload: Schema.Void, result: Schema.Void, @@ -186,7 +179,7 @@ export const clearCache = makeIpcMethod({ }), }); -export const getPreviewConfig = makeIpcMethod({ +export const getPreviewConfig = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, payload: DesktopPreviewConfigInputSchema, result: DesktopPreviewWebviewConfigSchema, @@ -196,12 +189,12 @@ export const getPreviewConfig = makeIpcMethod({ return { partition: yield* manager.getBrowserPartition(environmentId), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, - preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, + preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, }; }), }); -export const setAnnotationTheme = makeIpcMethod({ +export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, payload: DesktopPreviewAnnotationThemeInputSchema, result: Schema.Void, @@ -211,7 +204,7 @@ export const setAnnotationTheme = makeIpcMethod({ }), }); -export const pickElement = makeIpcMethod({ +export const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.NullOr(PreviewAnnotationPayloadSchema), @@ -221,7 +214,7 @@ export const pickElement = makeIpcMethod({ }), }); -export const captureScreenshot = makeIpcMethod({ +export const captureScreenshot = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, payload: DesktopPreviewTabInputSchema, result: DesktopPreviewScreenshotArtifactSchema, @@ -231,7 +224,7 @@ export const captureScreenshot = makeIpcMethod({ }), }); -export const revealArtifact = makeIpcMethod({ +export const revealArtifact = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, payload: DesktopPreviewArtifactInputSchema, result: Schema.Void, @@ -241,7 +234,7 @@ export const revealArtifact = makeIpcMethod({ }), }); -export const copyArtifactToClipboard = makeIpcMethod({ +export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, payload: DesktopPreviewArtifactInputSchema, result: Schema.Void, @@ -251,7 +244,7 @@ export const copyArtifactToClipboard = makeIpcMethod({ }), }); -export const automationStatus = makeIpcMethod({ +export const automationStatus = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, payload: DesktopPreviewTabInputSchema, result: PreviewAutomationStatus, @@ -261,7 +254,7 @@ export const automationStatus = makeIpcMethod({ }), }); -export const automationSnapshot = makeIpcMethod({ +export const automationSnapshot = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, payload: DesktopPreviewTabInputSchema, result: PreviewAutomationSnapshot, @@ -271,7 +264,7 @@ export const automationSnapshot = makeIpcMethod({ }), }); -export const automationClick = makeIpcMethod({ +export const automationClick = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, payload: DesktopPreviewAutomationClickInputSchema, result: Schema.Void, @@ -281,7 +274,7 @@ export const automationClick = makeIpcMethod({ }), }); -export const automationType = makeIpcMethod({ +export const automationType = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, payload: DesktopPreviewAutomationTypeInputSchema, result: Schema.Void, @@ -291,7 +284,7 @@ export const automationType = makeIpcMethod({ }), }); -export const automationPress = makeIpcMethod({ +export const automationPress = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, payload: DesktopPreviewAutomationPressInputSchema, result: Schema.Void, @@ -301,7 +294,7 @@ export const automationPress = makeIpcMethod({ }), }); -export const automationScroll = makeIpcMethod({ +export const automationScroll = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, payload: DesktopPreviewAutomationScrollInputSchema, result: Schema.Void, @@ -311,7 +304,7 @@ export const automationScroll = makeIpcMethod({ }), }); -export const automationEvaluate = makeIpcMethod({ +export const automationEvaluate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, payload: DesktopPreviewAutomationEvaluateInputSchema, result: Schema.Unknown, @@ -321,7 +314,7 @@ export const automationEvaluate = makeIpcMethod({ }), }); -export const automationWaitFor = makeIpcMethod({ +export const automationWaitFor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, payload: DesktopPreviewAutomationWaitForInputSchema, result: Schema.Void, @@ -331,7 +324,7 @@ export const automationWaitFor = makeIpcMethod({ }), }); -export const saveRecording = makeIpcMethod({ +export const saveRecording = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, payload: DesktopPreviewRecordingSaveInputSchema, result: DesktopPreviewRecordingArtifactSchema, diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts deleted file mode 100644 index bc5e4a9aeb2..00000000000 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts"; -import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; - -const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema); -const NonBlankString = Schema.String.check( - Schema.makeFilter((value) => - value.trim().length > 0 ? undefined : "Expected a non-empty string", - ), -); - -const SetSavedEnvironmentSecretInput = Schema.Struct({ - environmentId: EnvironmentId, - secret: NonBlankString, -}); - -export const getSavedEnvironmentRegistry = makeIpcMethod({ - channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - payload: Schema.Void, - result: SavedEnvironmentRegistryPayload, - handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return yield* savedEnvironments.getRegistry; - }), -}); - -export const setSavedEnvironmentRegistry = makeIpcMethod({ - channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - payload: SavedEnvironmentRegistryPayload, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry(records); - }), -}); - -export const getSavedEnvironmentSecret = makeIpcMethod({ - channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - payload: EnvironmentId, - result: Schema.NullOr(Schema.String), - handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); - }), -}); - -export const setSavedEnvironmentSecret = makeIpcMethod({ - channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - payload: SetSavedEnvironmentSecretInput, - result: Schema.Boolean, - handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({ - environmentId, - secret, - }) { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return yield* savedEnvironments.setSecret({ - environmentId, - secret, - }); - }), -}); - -export const removeSavedEnvironmentSecret = makeIpcMethod({ - channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - payload: EnvironmentId, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.removeSecret(environmentId); - }), -}); diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index cd0f215e193..9a9ce768973 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -9,14 +9,14 @@ import * as Schema from "effect/Schema"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopServerExposure from "../../backend/DesktopServerExposure.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; const SetTailscaleServeEnabledInput = Schema.Struct({ enabled: Schema.Boolean, port: Schema.optionalKey(Schema.Number), }); -export const getServerExposureState = makeIpcMethod({ +export const getServerExposureState = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL, payload: Schema.Void, result: DesktopServerExposureStateSchema, @@ -26,7 +26,7 @@ export const getServerExposureState = makeIpcMethod({ }), }); -export const setServerExposureMode = makeIpcMethod({ +export const setServerExposureMode = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, payload: DesktopServerExposureModeSchema, result: DesktopServerExposureStateSchema, @@ -41,7 +41,7 @@ export const setServerExposureMode = makeIpcMethod({ }), }); -export const setTailscaleServeEnabled = makeIpcMethod({ +export const setTailscaleServeEnabled = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, payload: SetTailscaleServeEnabledInput, result: DesktopServerExposureStateSchema, @@ -58,7 +58,7 @@ export const setTailscaleServeEnabled = makeIpcMethod({ }), }); -export const getAdvertisedEndpoints = makeIpcMethod({ +export const getAdvertisedEndpoints = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL, payload: Schema.Void, result: Schema.Array(AdvertisedEndpoint), diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 6eeaa3202d9..9c9af2a4e2b 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,11 +1,11 @@ import { bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, issueRemoteWebSocketTicket, RemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/authorization"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { EnvironmentAuthInvalidError, DesktopDiscoveredSshHostSchema, @@ -33,7 +33,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; @@ -107,7 +107,7 @@ const withLoopbackSshApi = ), ); -export const discoverSshHosts = makeIpcMethod({ +export const discoverSshHosts = DesktopIpc.makeIpcMethod({ channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, payload: Schema.Void, result: Schema.Array(DesktopDiscoveredSshHostSchema), @@ -117,7 +117,7 @@ export const discoverSshHosts = makeIpcMethod({ }), }); -export const ensureSshEnvironment = makeIpcMethod({ +export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentEnsureInputSchema, result: DesktopSshEnvironmentEnsureResultSchema, @@ -139,7 +139,7 @@ export const ensureSshEnvironment = makeIpcMethod({ }), }); -export const disconnectSshEnvironment = makeIpcMethod({ +export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({ channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentTargetSchema, result: Schema.Void, @@ -149,7 +149,7 @@ export const disconnectSshEnvironment = makeIpcMethod({ }), }); -export const fetchSshEnvironmentDescriptor = makeIpcMethod({ +export const fetchSshEnvironmentDescriptor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, payload: DesktopSshHttpBaseUrlInputSchema, result: ExecutionEnvironmentDescriptor, @@ -160,7 +160,7 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({ }), }); -export const bootstrapSshBearerSession = makeIpcMethod({ +export const bootstrapSshBearerSession = DesktopIpc.makeIpcMethod({ channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, payload: DesktopSshBearerBootstrapInputSchema, result: AuthAccessTokenResult, @@ -177,7 +177,7 @@ export const bootstrapSshBearerSession = makeIpcMethod({ }), }); -export const fetchSshSessionState = makeIpcMethod({ +export const fetchSshSessionState = DesktopIpc.makeIpcMethod({ channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, payload: DesktopSshBearerRequestInputSchema, result: AuthSessionState, @@ -194,7 +194,7 @@ export const fetchSshSessionState = makeIpcMethod({ }), }); -export const issueSshWebSocketTicket = makeIpcMethod({ +export const issueSshWebSocketTicket = DesktopIpc.makeIpcMethod({ channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, payload: DesktopSshBearerRequestInputSchema, result: AuthWebSocketTicketResult, @@ -211,7 +211,7 @@ export const issueSshWebSocketTicket = makeIpcMethod({ }), }); -export const resolveSshPasswordPrompt = makeIpcMethod({ +export const resolveSshPasswordPrompt = DesktopIpc.makeIpcMethod({ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, result: Schema.Void, diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index 45ea8502121..b2212609030 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -9,9 +9,9 @@ import * as Schema from "effect/Schema"; import * as DesktopUpdates from "../../updates/DesktopUpdates.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; -export const getUpdateState = makeIpcMethod({ +export const getUpdateState = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_GET_STATE_CHANNEL, payload: Schema.Void, result: DesktopUpdateStateSchema, @@ -21,7 +21,7 @@ export const getUpdateState = makeIpcMethod({ }), }); -export const setUpdateChannel = makeIpcMethod({ +export const setUpdateChannel = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, payload: DesktopUpdateChannelSchema, result: DesktopUpdateStateSchema, @@ -31,7 +31,7 @@ export const setUpdateChannel = makeIpcMethod({ }), }); -export const downloadUpdate = makeIpcMethod({ +export const downloadUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL, payload: Schema.Void, result: DesktopUpdateActionResultSchema, @@ -41,7 +41,7 @@ export const downloadUpdate = makeIpcMethod({ }), }); -export const installUpdate = makeIpcMethod({ +export const installUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_INSTALL_CHANNEL, payload: Schema.Void, result: DesktopUpdateActionResultSchema, @@ -51,7 +51,7 @@ export const installUpdate = makeIpcMethod({ }), }); -export const checkForUpdate = makeIpcMethod({ +export const checkForUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_CHECK_CHANNEL, payload: Schema.Void, result: DesktopUpdateCheckResultSchema, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 1cb4d7265a1..3cb705d0361 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; @@ -17,7 +18,7 @@ import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; const ContextMenuPosition = Schema.Struct({ x: Schema.Number, @@ -35,7 +36,7 @@ function toWebSocketBaseUrl(httpBaseUrl: URL): string { return url.href; } -export const getAppBranding = makeSyncIpcMethod({ +export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_APP_BRANDING_CHANNEL, result: Schema.NullOr(DesktopAppBrandingSchema), handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () { @@ -44,7 +45,7 @@ export const getAppBranding = makeSyncIpcMethod({ }), }); -export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ +export const getLocalEnvironmentBootstrap = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { @@ -64,7 +65,17 @@ export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ }), }); -export const pickFolder = makeIpcMethod({ +export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL, + payload: Schema.Void, + result: Schema.String, + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBearerToken")(function* () { + const localAuth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; + return yield* localAuth.getBearerToken; + }), +}); + +export const pickFolder = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), result: Schema.NullOr(Schema.String), @@ -80,7 +91,7 @@ export const pickFolder = makeIpcMethod({ }), }); -export const confirm = makeIpcMethod({ +export const confirm = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CONFIRM_CHANNEL, payload: Schema.String, result: Schema.Boolean, @@ -93,7 +104,7 @@ export const confirm = makeIpcMethod({ }), }); -export const setTheme = makeIpcMethod({ +export const setTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, result: Schema.Void, @@ -103,7 +114,7 @@ export const setTheme = makeIpcMethod({ }), }); -export const showContextMenu = makeIpcMethod({ +export const showContextMenu = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CONTEXT_MENU_CHANNEL, payload: ContextMenuInput, result: Schema.NullOr(Schema.String), @@ -124,7 +135,7 @@ export const showContextMenu = makeIpcMethod({ }), }); -export const openExternal = makeIpcMethod({ +export const openExternal = DesktopIpc.makeIpcMethod({ channel: IpcChannels.OPEN_EXTERNAL_CHANNEL, payload: Schema.String, result: Schema.Boolean, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3ed0b9b5cf0..b88eb18e57f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,27 +14,29 @@ import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; -import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; -import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; +import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; -import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; -import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; +import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; +import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; +import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; +import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; @@ -45,7 +47,7 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; -import * as PreviewBrowserSession from "./preview/BrowserSession.ts"; +import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; @@ -67,8 +69,8 @@ const desktopEnvironmentLayer = Layer.unwrap( ); const resolveDesktopSshCliRunner = ( - environment: DesktopEnvironment.DesktopEnvironmentShape, - settings: DesktopSettingsValue, + environment: DesktopEnvironment.DesktopEnvironment["Service"], + settings: DesktopAppSettings.DesktopSettings, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -104,21 +106,20 @@ const electronLayer = Layer.mergeAll( ElectronDialog.layer, ElectronMenu.layer, ElectronProtocol.layer, - DesktopSecretStorage.layer, + ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), + DesktopIpc.layer(Electron.ipcMain), ); const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, - DesktopLifecycle.layerShutdown, + DesktopShutdown.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, - DesktopSavedEnvironments.layer, - DesktopCloudAuthTokenStore.layer, + DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); @@ -128,12 +129,12 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( ); const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), + Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopFoundationLayer), ); const desktopPreviewLayer = PreviewManager.layer.pipe( - Layer.provideMerge(PreviewBrowserSession.layer), + Layer.provideMerge(BrowserSession.layer), Layer.provideMerge(desktopFoundationLayer), ); @@ -148,17 +149,30 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe( Layer.provideMerge(desktopWindowLayer), ); +const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provideMerge(desktopBackendLayer), +); + const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, - DesktopCloudAuth.layer, DesktopShellEnvironment.layer, desktopSshLayer, -).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); +).pipe( + Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(desktopLocalEnvironmentAuthLayer), +); + +const desktopClerkLayer = DesktopClerk.layer.pipe( + Layer.provideMerge(desktopEnvironmentLayer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ElectronApp.layer), +); -const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( - Layer.flatMap(() => +const desktopRuntimeLayer = desktopClerkLayer.pipe( + Layer.flatMap((clerkContext) => desktopApplicationLayer.pipe( + Layer.provideMerge(Layer.succeedContext(clerkContext)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(NetService.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ce12f19bf72..6f126f41334 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,10 +4,13 @@ import type { DesktopPreviewRecordingFrame, DesktopPreviewTabState, } from "@t3tools/contracts"; +import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; +exposeClerkBridge({ passkeys: true }); + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && @@ -39,19 +42,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getLocalEnvironmentBearerToken: () => + ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), - getSavedEnvironmentRegistry: () => - ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), - setSavedEnvironmentRegistry: (records) => - ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), - getSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), - setSavedEnvironmentSecret: (environmentId, secret) => - ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), - removeSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), + setConnectionCatalog: (catalog) => + ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), + clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( @@ -101,23 +100,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), - createCloudAuthRequest: () => ipcRenderer.invoke(IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL), - getCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL), - setCloudAuthToken: (token: string) => - ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token), - clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL), - fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input), - onCloudAuthCallback: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { - if (typeof rawUrl !== "string") return; - listener(rawUrl); - }; - - ipcRenderer.on(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); - return () => { - ipcRenderer.removeListener(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); - }; - }, onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 5526e5e0e54..e258bb2dfc5 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { beforeEach, vi } from "vite-plus/test"; const { fromPartition, sessions } = vi.hoisted(() => ({ @@ -59,6 +61,64 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("preserves partition scope and the platform failure chain", () => { + const nativeCause = new Error("native digest failed"); + const platformCause = PlatformError.systemError({ + _tag: "Unknown", + module: "Crypto", + method: "digest", + cause: nativeCause, + }); + const failingCryptoLayer = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: () => Effect.fail(platformCause), + }), + ); + + return Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); + + assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); + assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); + assert.isTrue(BrowserSession.isBrowserSessionError(error)); + assert.equal(error.scope, "environment-a"); + assert.strictEqual(error.cause, platformCause); + assert.strictEqual(error.cause.reason.cause, nativeCause); + assert.equal( + error.message, + "Failed to derive a desktop preview browser partition for scope environment-a.", + ); + assert.notInclude(error.message, nativeCause.message); + }).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(failingCryptoLayer)))); + }); + + it.effect("preserves session scope, partition, and the Electron failure", () => + Effect.gen(function* () { + const cause = new Error("Electron session failed"); + fromPartition.mockImplementationOnce(() => { + throw cause; + }); + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("environment-b"); + const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); + + assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); + assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); + assert.isTrue(BrowserSession.isBrowserSessionError(error)); + assert.equal(error.scope, "environment-b"); + assert.equal(error.partition, partition); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to create a desktop preview browser session for scope environment-b (partition ${partition}).`, + ); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears storage and cache for every created session", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -80,4 +140,52 @@ describe("BrowserSession", () => { } }).pipe(Effect.provide(layer)), ); + + it.effect("correlates clear failures while still attempting every session", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("scope-b"); + const firstPartition = yield* browserSessions.getPartition("scope-a"); + const secondPartition = yield* browserSessions.getPartition("scope-b"); + const firstSession = sessions.get(firstPartition); + const secondSession = sessions.get(secondPartition); + assert.isDefined(firstSession); + assert.isDefined(secondSession); + + const storageCause = new Error("storage clear failed"); + secondSession.clearStorageData.mockImplementationOnce(() => Promise.reject(storageCause)); + const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); + + assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); + assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); + assert.equal(storageError.partition, secondPartition); + assert.strictEqual(storageError.cause, storageCause); + assert.equal( + storageError.message, + `Failed to clear desktop preview browser storage for partition ${secondPartition}.`, + ); + assert.notInclude(storageError.message, storageCause.message); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); + } + + const cacheCause = new Error("cache clear failed"); + firstSession.clearCache.mockImplementationOnce(() => Promise.reject(cacheCause)); + const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); + + assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); + assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); + assert.equal(cacheError.partition, firstPartition); + assert.strictEqual(cacheError.cause, cacheCause); + assert.equal( + cacheError.message, + `Failed to clear the desktop preview browser cache for partition ${firstPartition}.`, + ); + assert.notInclude(cacheError.message, cacheCause.message); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); + } + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index ead28c12f9b..afa8dafe976 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -2,45 +2,107 @@ import type { Session } from "electron"; import { session } from "electron"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")<{ - readonly operation: string; - readonly cause: unknown; -}> { - override get message() { - return `Desktop preview browser session operation failed: ${this.operation}`; +export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( + "BrowserSessionPartitionDerivationError", + { + scope: Schema.String, + cause: Schema.instanceOf(PlatformError.PlatformError), + }, +) { + override get message(): string { + return `Failed to derive a desktop preview browser partition for scope ${this.scope}.`; } } -export interface BrowserSessionShape { - readonly getPartition: (scope?: string) => Effect.Effect; - readonly isPartition: (partition: string) => boolean; - readonly getSession: (scope?: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; +export class BrowserSessionCreationError extends Schema.TaggedErrorClass()( + "BrowserSessionCreationError", + { + scope: Schema.String, + partition: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create a desktop preview browser session for scope ${this.scope} (partition ${this.partition}).`; + } +} + +export class BrowserSessionStorageClearError extends Schema.TaggedErrorClass()( + "BrowserSessionStorageClearError", + { + partition: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to clear desktop preview browser storage for partition ${this.partition}.`; + } } -export class BrowserSession extends Context.Service()( - "@t3tools/desktop/preview/BrowserSession", -) {} +export class BrowserSessionCacheClearError extends Schema.TaggedErrorClass()( + "BrowserSessionCacheClearError", + { + partition: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to clear the desktop preview browser cache for partition ${this.partition}.`; + } +} + +export const BrowserSessionGetSessionError = Schema.Union([ + BrowserSessionPartitionDerivationError, + BrowserSessionCreationError, +]); +export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; +export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); + +export const BrowserSessionError = Schema.Union([ + BrowserSessionPartitionDerivationError, + BrowserSessionCreationError, + BrowserSessionStorageClearError, + BrowserSessionCacheClearError, +]); +export type BrowserSessionError = typeof BrowserSessionError.Type; +export const isBrowserSessionError = Schema.is(BrowserSessionError); -const make = Effect.gen(function* BrowserSessionMake() { +export class BrowserSession extends Context.Service< + BrowserSession, + { + readonly getPartition: ( + scope?: string, + ) => Effect.Effect; + readonly isPartition: (partition: string) => boolean; + readonly getSession: (scope?: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; + } +>()("@t3tools/desktop/preview/BrowserSession") {} + +export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") { - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(scope)) - .pipe( - Effect.mapError((cause) => new BrowserSessionError({ operation: "getPartition", cause })), - ); + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe( + Effect.mapError( + (cause) => + new BrowserSessionPartitionDerivationError({ + scope, + cause, + }), + ), + ); return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; }); @@ -65,7 +127,12 @@ const make = Effect.gen(function* BrowserSessionMake() { next.set(partition, browserSession); return [browserSession, next] as const; }, - catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }), + catch: (cause) => + new BrowserSessionCreationError({ + scope, + partition, + cause, + }), }); }); }); @@ -77,13 +144,17 @@ const make = Effect.gen(function* BrowserSessionMake() { clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.values()].map((browserSession) => + [...sessions.entries()].map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], }), - catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), + catch: (cause) => + new BrowserSessionStorageClearError({ + partition, + cause, + }), }), ), { concurrency: "unbounded", discard: true }, @@ -92,10 +163,14 @@ const make = Effect.gen(function* BrowserSessionMake() { clearCache: Effect.fn("BrowserSession.clearCache")(function* () { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.values()].map((browserSession) => + [...sessions.entries()].map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearCache(), - catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), + catch: (cause) => + new BrowserSessionCacheClearError({ + partition, + cause, + }), }), ), { concurrency: "unbounded", discard: true }, diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index d7252d3f8d9..acb0d783a82 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -5,19 +5,22 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; 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 type * as Scope from "effect/Scope"; import { TestClock } from "effect/testing"; -import { beforeEach, describe, expect, vi } from "vite-plus/test"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } = vi.hoisted(() => ({ - createFromPath: vi.fn(() => ({ isEmpty: () => false })), + createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), fromId: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), @@ -59,7 +62,7 @@ const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", - } as DesktopEnvironment.DesktopEnvironmentShape), + } as DesktopEnvironment.DesktopEnvironment["Service"]), ); const fileSystemLayer = FileSystem.layerNoop({ @@ -79,10 +82,11 @@ const layer = PreviewManager.layer.pipe( Layer.provideMerge(fileSystemLayer), Layer.provideMerge(Path.layer), ); +const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); const withManager = ( use: ( - manager: PreviewManager.PreviewManagerShape, + manager: PreviewManager.PreviewManager["Service"], ) => Effect.Effect, ) => Effect.gen(function* () { @@ -128,6 +132,124 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { + const loggedErrors: Array = []; + const logger = Logger.make(({ message }) => { + for (const value of Array.isArray(message) ? message : [message]) { + if (typeof value === "object" && value !== null && "cause" in value) { + loggedErrors.push(Cause.squash(value.cause as Cause.Cause)); + } + } + }); + const deliveryError = new ElectronWindow.ElectronWindowOperationError({ + operation: "send-window-message", + platform: "darwin", + windowId: 42, + channel: "preview:state-change", + cause: new Error("renderer unavailable"), + }); + const delivered = vi.fn(); + + return withManager((manager) => + Effect.gen(function* () { + yield* manager.subscribeStateChanges(() => Effect.die(deliveryError)); + yield* manager.subscribeStateChanges((tabId, state) => + Effect.sync(() => { + delivered(tabId, state); + }), + ); + + const state = yield* manager.createTab("tab_listener_failure"); + + expect(delivered).toHaveBeenCalledOnce(); + expect(delivered).toHaveBeenCalledWith("tab_listener_failure", state); + expect(loggedErrors).toHaveLength(1); + expect(loggedErrors[0]).toBeInstanceOf(ElectronWindow.ElectronWindowOperationError); + expect(loggedErrors[0]).toMatchObject({ + operation: "send-window-message", + windowId: 42, + channel: "preview:state-change", + }); + }), + ).pipe( + Effect.provide( + Logger.layer([logger], { + mergeWithExisting: false, + }), + ), + ); + }); + + effectIt.effect("does not swallow state listener interruption", () => + withManager((manager) => + Effect.gen(function* () { + const exit = yield* Effect.scoped( + Effect.gen(function* () { + yield* manager.subscribeStateChanges(() => Effect.interrupt); + return yield* Effect.exit(manager.createTab("tab_interrupted_listener")); + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + } + }), + ), + ); + + effectIt.effect("queues navigation until the webview registers", () => + withManager((manager) => + Effect.gen(function* () { + const loadURL = vi.fn(async () => undefined); + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "about:blank", + getTitle: () => "", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + loadURL, + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.navigate("tab_pending", "localhost:3200"); + + expect(yield* manager.automationStatus("tab_pending")).toEqual({ + available: false, + visible: true, + tabId: "tab_pending", + url: "http://localhost:3200/", + title: "", + loading: true, + }); + + yield* manager.registerWebview("tab_pending", 42); + yield* Effect.yieldNow; + + expect(loadURL).toHaveBeenCalledOnce(); + expect(loadURL).toHaveBeenCalledWith("http://localhost:3200/"); + }), + ), + ); + effectIt.effect("captures a PNG screenshot into browser artifacts", () => withManager((manager) => Effect.gen(function* () { @@ -185,6 +307,20 @@ describe("PreviewManager", () => { expect(artifact.path).toMatch( /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, ); + + const captureCause = new Error("capture failed"); + capturePage.mockRejectedValueOnce(captureCause); + const exit = yield* Effect.exit(manager.captureScreenshot("tab_1")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewOperationError", + operation: "captureScreenshot.capturePage", + tabId: "tab_1", + webContentsId: 42, + cause: captureCause, + }); }), ), ); @@ -250,9 +386,12 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - message: "Preview artifact path is outside the configured artifact directory.", + expect(error).toMatchObject({ + _tag: "PreviewArtifactPathOutsideDirectoryError", + artifactPath: "/tmp/t3/dev/settings.json", + artifactDirectory: "/tmp/t3/dev/browser-artifacts", }); + expect("cause" in error).toBe(false); }), ), ); @@ -272,8 +411,20 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - message: "Preview artifact path is outside the configured artifact directory.", + expect(error).toMatchObject({ + _tag: "PreviewArtifactPathOutsideDirectoryError", + artifactPath: "/tmp/t3/dev/settings.json", + artifactDirectory: "/tmp/t3/dev/browser-artifacts", + }); + expect("cause" in error).toBe(false); + + createFromPath.mockReturnValueOnce({ isEmpty: () => true }); + const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); + expect(Exit.isFailure(invalidImageExit)).toBe(true); + if (Exit.isSuccess(invalidImageExit)) return; + expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({ + _tag: "PreviewArtifactImageLoadError", + artifactPath, }); }), ), @@ -328,7 +479,11 @@ describe("PreviewManager", () => { }, } as never); - yield* manager.subscribePointerEvents((event) => activity.push(event.phase)); + yield* manager.subscribePointerEvents((event) => + Effect.sync(() => { + activity.push(event.phase); + }), + ); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); const click = yield* manager @@ -414,10 +569,174 @@ describe("PreviewManager", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error.cause).toMatchObject({ - name: "PreviewAutomationControlInterruptedError", + expect(error).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + operation: "click", + tabId: "tab_1", + webContentsId: 42, }); + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.name).toBe("PreviewAutomationControlInterruptedError"); + } + expect("cause" in error).toBe(false); }), ), ); + + effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () => + withManager((manager) => + Effect.gen(function* () { + const text = "ReferenceError: fallbackDetail is not defined"; + const exceptionDetails = { + text, + exception: { description: "" }, + }; + const sendCommand = vi.fn(async (method: string) => + method === "Runtime.evaluate" ? { exceptionDetails } : undefined, + ); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const exit = yield* Effect.exit( + manager.automationEvaluate("tab_1", { expression: "fallbackDetail" }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewAutomationEvaluationError", + detailKind: "exception-text", + detailLength: text.length, + cause: exceptionDetails, + }); + }), + ), + ); +}); + +describe("PreviewOperationError", () => { + it("keeps timeline detail separate from its structured message", () => { + const cause = new Error("CDP command failed with an invalid node id"); + const error = new PreviewManager.PreviewOperationError({ + operation: "click.DOM.resolveNode", + tabId: "tab_1", + webContentsId: 42, + cause, + }); + + expect(error.message).not.toContain(cause.message); + expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message); + }); +}); + +describe("Preview automation diagnostics", () => { + it("keeps browser exception detail out of structural diagnostics", () => { + const secret = "unrelated-browser-payload-secret"; + const detail = "ReferenceError: missingValue is not defined"; + const cause = { + text: "Uncaught Error", + exception: { description: detail }, + unsafePayload: secret, + }; + const error = new PreviewManager.PreviewAutomationEvaluationError({ + tabId: "tab_1", + detailKind: "exception-description", + detailLength: detail.length, + cause, + }); + + const encoded = encodePreviewManagerError(error); + const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & { + readonly cause?: unknown; + }; + + expect(error.cause).toBe(cause); + expect(encodedCause).toStrictEqual(cause); + expect(error.message).toBe("Preview JavaScript evaluation failed in tab tab_1"); + expect(error.message).not.toContain(secret); + expect(JSON.stringify(encodedDiagnostics)).not.toContain(secret); + expect("detail" in error).toBe(false); + expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).toBe(detail); + expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).not.toContain( + secret, + ); + }); + + it("retains bounded selector diagnostics without exposing selector or reason text", () => { + const selector = "role=button[name='selector-secret']"; + const reason = "Unexpected token near reason-secret"; + const cause = { invalidSelector: true as const, message: reason }; + const error = new PreviewManager.PreviewAutomationInvalidSelectorError({ + operation: "click", + tabId: "tab_1", + selectorKind: "locator", + selectorLength: selector.length, + reasonLength: reason.length, + cause, + }); + + const encoded = encodePreviewManagerError(error); + const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & { + readonly cause?: unknown; + }; + + expect(error.cause).toBe(cause); + expect(encodedCause).toStrictEqual(cause); + expect(error).toMatchObject({ + selectorKind: "locator", + selectorLength: selector.length, + reasonLength: reason.length, + }); + expect(error.detail).toEqual({ + selectorKind: "locator", + selectorLength: selector.length, + }); + expect(error.message).not.toContain("secret"); + expect(JSON.stringify(encodedDiagnostics)).not.toContain("secret"); + expect("selector" in error).toBe(false); + expect("reason" in error).toBe(false); + expect(PreviewManager.PreviewAutomationInvalidSelectorError.toTimelineMessage(error)).toBe( + reason, + ); + }); + + it("does not retain a missing target locator", () => { + const selector = "[data-token='target-secret']"; + const error = new PreviewManager.PreviewAutomationTargetNotFoundError({ + operation: "scroll", + tabId: "tab_1", + selectorKind: "selector", + selectorLength: selector.length, + }); + + expect(error.message).not.toContain(selector); + expect(JSON.stringify(error)).not.toContain(selector); + expect("locator" in error).toBe(false); + }); }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2c4096e8cfb..6fd65cd25b5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -37,7 +37,6 @@ import { import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -149,22 +148,58 @@ interface CdpEvaluationResult { }; } -const automationError = ( - tag: - | "PreviewAutomationExecutionError" - | "PreviewAutomationInvalidSelectorError" - | "PreviewAutomationResultTooLargeError" - | "PreviewAutomationTimeoutError" - | "PreviewAutomationControlInterruptedError", - message: string, - detail?: unknown, -): Error & { detail?: unknown } => { - const error = new Error(message) as Error & { detail?: unknown }; - error.name = tag; - if (detail !== undefined) error.detail = detail; - return error; +export const PreviewAutomationSelectorKind = Schema.Literals([ + "focused-element", + "selector", + "locator", +]); +export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type; + +export const PreviewAutomationEvaluationDetailKind = Schema.Literals([ + "exception-description", + "exception-text", + "unknown", +]); +export type PreviewAutomationEvaluationDetailKind = + typeof PreviewAutomationEvaluationDetailKind.Type; + +const previewAutomationEvaluationDetail = (exceptionDetails: unknown) => { + if (typeof exceptionDetails !== "object" || exceptionDetails === null) { + return { detailKind: "unknown" as const }; + } + const details = exceptionDetails as Record; + const exception = details["exception"]; + const description = + typeof exception === "object" && + exception !== null && + typeof (exception as Record)["description"] === "string" + ? (exception as Record)["description"] + : undefined; + if (typeof description === "string" && description.length > 0) { + return { detailKind: "exception-description" as const, detail: description }; + } + const text = details["text"]; + if (typeof text === "string" && text.length > 0) { + return { detailKind: "exception-text" as const, detail: text }; + } + return { detailKind: "unknown" as const }; }; +const previewAutomationTargetLabel = ( + selectorKind: PreviewAutomationSelectorKind, + selectorLength?: number, +) => + selectorKind === "focused-element" + ? "the focused element" + : `${selectorKind} (${selectorLength ?? 0} characters)`; + +interface PreviewOperationContext { + readonly operation: string; + readonly tabId?: string; + readonly webContentsId?: number; + readonly artifactPath?: string; +} + const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { if (typeof value !== "object" || value === null) return null; const rect = value as Record; @@ -195,6 +230,7 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; const captureAnnotationScreenshot = ( + tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, ): Effect.Effect => @@ -210,7 +246,13 @@ const captureAnnotationScreenshot = ( } : undefined, ), - catch: (cause) => new PreviewManagerError({ operation: "captureAnnotationScreenshot", cause }), + catch: (cause) => + new PreviewOperationError({ + operation: "captureAnnotationScreenshot", + tabId, + webContentsId: wc.id, + cause, + }), }).pipe( Effect.map((image) => { const size = image.getSize(); @@ -239,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; }; -type Listener = (tabId: string, state: PreviewTabState) => void; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; +type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; +type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -271,7 +313,7 @@ interface BrowserDiagnostics { readonly requests: ReadonlyMap; } -type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; +type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; interface ExpectedAgentInput { readonly signal: PreviewInputSignal; @@ -342,15 +384,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); const playwrightInstallExpression = yield* Effect.cached( - playwrightInjectedRuntimeInstallExpression().pipe( - Effect.mapError( - (cause) => - new PreviewManagerError({ - operation: "ensurePlaywrightInjected", - cause, - }), - ), - ), + playwrightInjectedRuntimeInstallExpression(), ); const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); @@ -378,16 +412,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pointerSequenceRef = yield* Ref.make(0); const recordingTabIdRef = yield* Ref.make>(Option.none()); - const fail = (operation: string, cause: unknown): PreviewManagerError => - new PreviewManagerError({ operation, cause }); - const attempt = (operation: string, evaluate: () => A) => - Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) }); - const attemptPromise = (operation: string, evaluate: () => PromiseLike) => - Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) }); + const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => + Effect.try({ + try: evaluate, + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }); + const attemptPromise = ( + errorContext: PreviewOperationContext, + evaluate: () => PromiseLike, + ) => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }); const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const currentMillis = Clock.currentTimeMillis; - const encodeJson = (operation: string, value: unknown) => - encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause))); + const encodeJson = (errorContext: PreviewOperationContext, value: unknown) => + encodeUnknownJson(value).pipe( + Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })), + ); const nextCounter = (ref: Ref.Ref) => Ref.modify(ref, (value) => [value, value + 1] as const); const replaceMap = ( @@ -399,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return copy; }; + const deliverEvent = ( + eventKind: "state-change" | "recording-frame" | "pointer-event", + tabId: string, + delivery: () => Effect.Effect, + ) => + Effect.suspend(delivery).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("Desktop preview event listener failed.", { + eventKind, + tabId, + cause, + }), + ), + ); + const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { const listeners = yield* Ref.get(listenersRef); yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore), + (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)), { discard: true }, ); }); @@ -432,22 +492,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const tabs = yield* SynchronizedRef.get(tabsRef); const tab = tabs.get(tabId); - if (!tab) return yield* fail("requireWebContents", new PreviewTabNotFoundError(tabId)); + if (!tab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } if (tab.webContentsId == null) { - return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError(tabId)); + return yield* new PreviewWebviewNotInitializedError({ tabId }); } const wc = webContents.fromId(tab.webContentsId); if (!wc) { - return yield* fail( - "requireWebContents", - new PreviewWebContentsNotFoundError(tabId, tab.webContentsId), - ); + return yield* new PreviewWebContentsNotFoundError({ + tabId, + webContentsId: tab.webContentsId, + }); } return wc; }); const resolveArtifactPath = (artifactPath: string) => - attempt("resolveArtifactPath", () => { + attempt({ operation: "resolveArtifactPath", artifactPath }, () => { const resolvedPath = path.resolve(artifactPath); const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath); if ( @@ -463,10 +525,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.flatMap((resolvedPath) => resolvedPath === null ? Effect.fail( - fail( - "resolveArtifactPath", - new Error("Preview artifact path is outside the configured artifact directory."), - ), + new PreviewArtifactPathOutsideDirectoryError({ + artifactPath, + artifactDirectory: resolvedArtifactDirectory, + }), ) : Effect.succeed(resolvedPath), ), @@ -635,129 +697,138 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( wc: Electron.WebContents, ) { - return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => { - const existing = sessions.get(wc.id); - if (existing) return Effect.succeed([existing, sessions] as const); - if (wc.isDevToolsOpened()) { - return Effect.fail( - fail( - "ensureControlSession", - automationError( - "PreviewAutomationExecutionError", - "Close preview DevTools before using agent browser control.", - ), - ), - ); - } - if (wc.debugger.isAttached()) { - return Effect.fail( - fail( - "ensureControlSession", - automationError( - "PreviewAutomationExecutionError", - "Preview control cannot attach because another debugger owns this page.", - ), - ), - ); - } - const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { - const semaphore = yield* Semaphore.make(1); - const scope = yield* Scope.fork(parentScope, "sequential"); - const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* ( - method: string, - params: Record, - ) { - if (method === "Page.screencastFrame") { - const sessionId = params["sessionId"]; - if (typeof sessionId === "number") { - yield* attemptPromise("ackScreencastFrame", () => - wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), - ).pipe(Effect.ignore); - } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), - { discard: true }, - ); - } - } - yield* captureDiagnosticMessage(wc.id, method, params); - }); - const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { - runFork(handleDebuggerMessage(method, params)); - }; - yield* Scope.addFinalizer( - scope, - Effect.all( - [ - Ref.update(diagnosticsRef, (diagnostics) => - replaceMap(diagnostics, (copy) => { - copy.delete(wc.id); - }), - ), - attempt("detachControlSession", () => { - wc.debugger.off("message", onMessage); - if (wc.debugger.isAttached()) wc.debugger.detach(); - }).pipe(Effect.ignore), - ], - { discard: true }, - ), - ); - const control: BrowserControlSession = { - webContentsId: wc.id, - semaphore, - scope, - onMessage, - }; - const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { - yield* Ref.update(diagnosticsRef, (diagnostics) => - replaceMap(diagnostics, (copy) => { - copy.set(wc.id, { - consoleEntries: [], - networkEntries: [], - requests: new Map(), - }); + return yield* SynchronizedRef.modifyEffect( + controlSessionsRef, + ( + sessions, + ): Effect.Effect< + readonly [BrowserControlSession, ReadonlyMap], + PreviewManagerError + > => { + const existing = sessions.get(wc.id); + if (existing) return Effect.succeed([existing, sessions] as const); + if (wc.isDevToolsOpened()) { + return Effect.fail( + new PreviewAutomationDevToolsOpenError({ + webContentsId: wc.id, }), ); - yield* attempt("attachDebuggerListeners", () => { - wc.debugger.on("message", onMessage); - wc.debugger.attach("1.3"); - }); - yield* Effect.all( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( - (method) => - attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), + } + if (wc.debugger.isAttached()) { + return Effect.fail( + new PreviewAutomationDebuggerAttachedError({ + webContentsId: wc.id, + }), + ); + } + const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { + const semaphore = yield* Semaphore.make(1); + const scope = yield* Scope.fork(parentScope, "sequential"); + const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( + function* (method: string, params: Record) { + if (method === "Page.screencastFrame") { + const sessionId = params["sessionId"]; + if (typeof sessionId === "number") { + yield* attemptPromise( + { + operation: "ackScreencastFrame", + webContentsId: wc.id, + }, + () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + ).pipe(Effect.ignore); + } + const tabId = yield* tabIdForWebContents(wc.id); + const metadata = + typeof params["metadata"] === "object" && params["metadata"] !== null + ? (params["metadata"] as Record) + : {}; + if (tabId && typeof params["data"] === "string") { + const receivedAt = yield* currentIso; + const listeners = yield* Ref.get(recordingFrameListenersRef); + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: params["data"], + width: + typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: + typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt, + }; + yield* Effect.forEach( + listeners, + (listener) => + deliverEvent("recording-frame", frame.tabId, () => listener(frame)), + { discard: true }, + ); + } + } + yield* captureDiagnosticMessage(wc.id, method, params); + }, + ); + const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { + runFork(handleDebuggerMessage(method, params)); + }; + yield* Scope.addFinalizer( + scope, + Effect.all( + [ + Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(wc.id); + }), + ), + attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { + wc.debugger.off("message", onMessage); + if (wc.debugger.isAttached()) wc.debugger.detach(); + }).pipe(Effect.ignore), + ], + { discard: true }, ), - { concurrency: "unbounded", discard: true }, ); - return [ - control, - replaceMap(sessions, (copy) => { - copy.set(wc.id, control); - }), - ] as const; + const control: BrowserControlSession = { + webContentsId: wc.id, + semaphore, + scope, + onMessage, + }; + const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.set(wc.id, { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }); + }), + ); + yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { + wc.debugger.on("message", onMessage); + wc.debugger.attach("1.3"); + }); + yield* Effect.all( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( + (method) => + attemptPromise( + { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, + () => wc.debugger.sendCommand(method), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + return [ + control, + replaceMap(sessions, (copy) => { + copy.set(wc.id, control); + }), + ] as const; + }); + return yield* initialize().pipe( + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + ); }); - return yield* initialize().pipe( - Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), - ); - }); - return createControlSession(); - }); + return createControlSession(); + }, + ); }); const pushAction = (tabId: string, event: PreviewAutomationActionEvent) => @@ -807,26 +878,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function function* (method, commandParams) { const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (before !== epoch) { - return yield* fail( - action, - automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", - ), - ); + return yield* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); } - const result = yield* attemptPromise(action, () => - wc.debugger.sendCommand(method, commandParams), + const result = yield* attemptPromise( + { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, + () => wc.debugger.sendCommand(method, commandParams), ); const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (after !== epoch) { - return yield* fail( - action, - automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", - ), - ); + return yield* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); } return result; }, @@ -845,15 +913,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); } else { const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); - const underlying = error instanceof PreviewManagerError ? error.cause : error; - const interrupted = - underlying instanceof Error && - underlying.name === "PreviewAutomationControlInterruptedError"; + const interrupted = isPreviewAutomationControlInterruptedError(error); + const errorMessage = isPreviewOperationError(error) + ? PreviewOperationError.toTimelineMessage(error) + : isPreviewAutomationEvaluationError(error) + ? PreviewAutomationEvaluationError.toTimelineMessage(error) + : isPreviewAutomationInvalidSelectorError(error) + ? PreviewAutomationInvalidSelectorError.toTimelineMessage(error) + : error instanceof Error + ? error.message + : String(error); yield* replaceAction(tabId, { ...actionEvent, status: interrupted ? "interrupted" : "failed", completedAt, - error: underlying instanceof Error ? underlying.message : String(underlying), + error: errorMessage, }); } const tabs = yield* SynchronizedRef.get(tabsRef); @@ -863,6 +937,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const evaluateWithDebugger = ( + tabId: string, send: SendCommand, expression: string, returnByValue: boolean, @@ -876,19 +951,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe( Effect.flatMap((rawResponse) => { const response = rawResponse as CdpEvaluationResult; - return response.exceptionDetails - ? Effect.fail( - fail( - "evaluate", - automationError( - "PreviewAutomationExecutionError", - response.exceptionDetails.exception?.description ?? - response.exceptionDetails.text ?? - "JavaScript evaluation failed.", - ), - ), - ) - : Effect.succeed(response.result?.value as A); + if (!response.exceptionDetails) { + return Effect.succeed(response.result?.value as A); + } + const detail = previewAutomationEvaluationDetail(response.exceptionDetails); + return Effect.fail( + new PreviewAutomationEvaluationError({ + tabId, + detailKind: detail.detailKind, + detailLength: detail.detail?.length ?? 0, + cause: response.exceptionDetails, + }), + ); }), ); @@ -897,17 +971,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function readonly locator?: string | undefined; }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null); + const automationSelectorDiagnostics = (input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): { + readonly selectorKind: PreviewAutomationSelectorKind; + readonly selectorLength?: number; + } => { + if (input.locator !== undefined) { + return { selectorKind: "locator", selectorLength: input.locator.length }; + } + if (input.selector !== undefined) { + return { selectorKind: "selector", selectorLength: input.selector.length }; + } + return { selectorKind: "focused-element" }; + }; + const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* ( + tabId: string, send: SendCommand, ) { const installed = yield* evaluateWithDebugger( + tabId, send, "Boolean(globalThis.__t3PlaywrightInjected)", true, ); if (installed) return; - const expression = yield* playwrightInstallExpression; - yield* evaluateWithDebugger(send, expression, true); + const expression = yield* playwrightInstallExpression.pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "ensurePlaywrightInjected", + tabId, + cause, + }), + ), + ); + yield* evaluateWithDebugger(tabId, send, expression, true); }); const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( @@ -1059,7 +1160,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; yield* Scope.addFinalizer( scope, - attempt("detachListeners", () => { + attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { wc.off("did-navigate", sync); wc.off("did-navigate-in-page", sync); wc.off("page-title-updated", sync); @@ -1071,7 +1172,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { - yield* attempt("attachListeners", () => { + yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { wc.on("did-navigate", sync); wc.on("did-navigate-in-page", sync); wc.on("page-title-updated", sync); @@ -1080,7 +1181,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.setWindowOpenHandler(({ url }) => { - runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); + runFork( + attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => + wc.loadURL(url), + ).pipe(Effect.ignore), + ); return { action: "deny" }; }); wc.on("before-input-event", beforeInput); @@ -1161,7 +1266,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) { - return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId)); + return yield* new PreviewTabNotFoundError({ tabId }); } const wc = webContents.fromId(webContentsId); const mainWindow = yield* Ref.get(mainWindowRef); @@ -1170,15 +1275,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.getType() !== "webview" || (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) ) { - return yield* fail( - "registerWebview", - new PreviewWebContentsNotFoundError(tabId, webContentsId), - ); + return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); } const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { - yield* attempt("registerWebview.sendTheme", () => + yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); return; @@ -1195,31 +1297,114 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } yield* attachListeners(tabId, wc); runFork(ensureControlSession(wc).pipe(Effect.ignore)); - if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { - yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe( - Effect.ignore, - ); - } - yield* update(tabId, { - webContentsId, - navStatus: computeNavStatus(wc), - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor: tab.zoomFactor, + const registeredAt = yield* currentIso; + const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) { + return [ + Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), + tabs, + ] as const; + } + const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const next: PreviewTabState = { + ...current, + webContentsId, + navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + updatedAt: registeredAt, + }; + return [ + Option.some({ + state: next, + pendingUrl, + }), + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), + ] as const; }); - yield* attempt("registerWebview.sendTheme", () => + if (Option.isNone(registration)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const { state: registered, pendingUrl } = registration.value; + yield* emit(tabId, registered); + if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { + yield* attempt({ operation: "registerWebview.restoreZoom", tabId, webContentsId }, () => + wc.setZoomFactor(registered.zoomFactor), + ).pipe(Effect.ignore); + } + yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); + const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus; + if ( + pendingUrl && + latestNavStatus?.kind === "Loading" && + latestNavStatus.url === pendingUrl && + wc.getURL() !== pendingUrl + ) { + runFork( + attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () => + wc.loadURL(pendingUrl), + ).pipe(Effect.ignore), + ); + } }); const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { - const wc = yield* requireWebContents(tabId); - const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl)); + const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => + normalizePreviewUrl(rawUrl), + ); + const updatedAt = yield* currentIso; + const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + const next: PreviewTabState = { + tabId, + webContentsId: current?.webContentsId ?? null, + navStatus: { + kind: "Loading", + url, + title: current?.navStatus.kind === "Idle" || !current ? "" : current.navStatus.title, + }, + canGoBack: current?.canGoBack ?? false, + canGoForward: current?.canGoForward ?? false, + zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, + controller: current?.controller ?? "none", + updatedAt, + }; + return [ + next, + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), + ] as const; + }); + yield* emit(tabId, pending); + if (pending.webContentsId == null) return; + const wc = webContents.fromId(pending.webContentsId); + if (!wc) { + const detached = { ...pending, webContentsId: null }; + yield* SynchronizedRef.update(tabsRef, (tabs) => + tabs.get(tabId)?.webContentsId !== pending.webContentsId + ? tabs + : replaceMap(tabs, (copy) => { + copy.set(tabId, detached); + }), + ); + yield* emit(tabId, detached); + return; + } if (wc.getURL() === url) { - yield* attempt("navigate.reload", () => wc.reload()); + yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => + wc.reload(), + ); return; } - yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url)); + yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => + wc.loadURL(url), + ); }); const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( @@ -1228,7 +1413,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function use: (wc: Electron.WebContents) => void, ) { const wc = yield* requireWebContents(tabId); - yield* attempt(operation, () => use(wc)); + yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); }); const goBack = (tabId: string) => @@ -1246,11 +1431,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); if (wc.isDevToolsOpened()) { - yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus()); + yield* attempt({ operation: "openDevTools.focus", tabId, webContentsId: wc.id }, () => + wc.devToolsWebContents?.focus(), + ); return; } yield* detachControlSession(wc.id); - yield* attempt("openDevTools", () => { + yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { wc.once("devtools-closed", () => { if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); }); @@ -1270,9 +1457,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = webContents.fromId(tab.webContentsId); return !wc || wc.isDestroyed() ? Effect.void - : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe( - Effect.ignore, - ); + : attempt( + { + operation: "setAnnotationTheme", + tabId: tab.tabId, + webContentsId: tab.webContentsId, + }, + () => wc.send(ANNOTATION_THEME_CHANNEL, theme), + ).pipe(Effect.ignore); }, { discard: true }, ); @@ -1285,7 +1477,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* Effect.callback( (resume) => { const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { - yield* attempt("pickElement.cleanup", () => { + yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); @@ -1314,9 +1506,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (activeTab?.webContentsId != null) { const activeWc = webContents.fromId(activeTab.webContentsId); if (activeWc && !activeWc.isDestroyed()) { - yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe( - Effect.ignore, - ); + yield* attempt( + { + operation: "cancelPickElement", + tabId, + webContentsId: activeWc.id, + }, + () => activeWc.send(CANCEL_PICK_CHANNEL), + ).pipe(Effect.ignore); } } resume(Effect.succeed(null)); @@ -1330,15 +1527,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const cropRect = normalizeCaptureRect(args[1]); runFork( - captureAnnotationScreenshot(wc, cropRect).pipe( + captureAnnotationScreenshot(tabId, wc, cropRect).pipe( Effect.matchEffect({ onFailure: () => Effect.sync(() => settle(payload)), onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), }), Effect.ensuring( - attempt("pickElement.captureComplete", () => { - if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); - }).pipe(Effect.ignore), + attempt( + { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, + () => { + if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); + }, + ).pipe(Effect.ignore), ), ), ); @@ -1353,7 +1553,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { - yield* attempt("pickElement.register", () => { + yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); wc.once("destroyed", onDestroyed); wc.once("did-start-navigation", onNavigated); @@ -1390,7 +1590,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (tab.webContentsId != null) { const wc = webContents.fromId(tab.webContentsId); if (wc && !wc.isDestroyed()) { - yield* attempt("applyZoom", () => wc.setZoomFactor(next)); + yield* attempt({ operation: "applyZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(next), + ); } } yield* update(tabId, { zoomFactor: next }); @@ -1403,17 +1605,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - attemptPromise("captureScreenshot.capturePage", () => wc.capturePage()), + attemptPromise( + { + operation: "captureScreenshot.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ), ]); const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`); const data = image.toPNG(); - yield* fileSystem - .makeDirectory(resolvedArtifactDirectory, { recursive: true }) - .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause))); - yield* fileSystem - .writeFile(artifactPath, data) - .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause))); + yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "captureScreenshot.makeDirectory", + tabId, + webContentsId: wc.id, + artifactPath, + cause, + }), + ), + ); + yield* fileSystem.writeFile(artifactPath, data).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "captureScreenshot.writeFile", + tabId, + webContentsId: wc.id, + artifactPath, + cause, + }), + ), + ); return { id, tabId, @@ -1440,10 +1667,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { const recordingTabId = yield* Ref.get(recordingTabIdRef); if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { - return yield* fail( - "startRecording", - new Error("Only one browser recording can be active per window."), - ); + return yield* new PreviewRecordingAlreadyActiveError({ + requestedTabId: tabId, + activeTabId: recordingTabId.value, + }); } const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "recording.start", startScreencast); @@ -1469,12 +1696,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const id = `browser-recording-${millis.toString(36)}`; const extension = mimeType.includes("mp4") ? "mp4" : "webm"; const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); - yield* fileSystem - .makeDirectory(resolvedArtifactDirectory, { recursive: true }) - .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause))); - yield* fileSystem - .writeFile(artifactPath, data) - .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause))); + yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "saveRecording.makeDirectory", + tabId, + artifactPath, + cause, + }), + ), + ); + yield* fileSystem.writeFile(artifactPath, data).pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "saveRecording.writeFile", + tabId, + artifactPath, + cause, + }), + ), + ); return { id, tabId, @@ -1531,6 +1774,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function visibleText: string; interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; }>( + tabId, send, `(() => { const selectorFor = (element) => { @@ -1587,7 +1831,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ send("Accessibility.getFullAXTree"), - attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), + attemptPromise( + { + operation: "automationSnapshot.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ), Ref.get(diagnosticsRef), Ref.get(actionTimelineRef), ]); @@ -1624,6 +1875,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* ( + tabId: string, send: SendCommand, input: PreviewAutomationClickInput, ) { @@ -1631,11 +1883,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return { x: input.x!, y: input.y! }; } const locator = automationLocator(input)!; - yield* ensurePlaywrightInjected(send); - const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator); + yield* ensurePlaywrightInjected(tabId, send); + const locatorJson = yield* encodeJson( + { operation: "automationClick.encodeLocator", tabId }, + locator, + ); const point = yield* evaluateWithDebugger< { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1656,21 +1912,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in point) { - return yield* fail( - "automationClick", - automationError("PreviewAutomationInvalidSelectorError", point.message, { - selector: locator, - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: point.message.length, + cause: point, + }); } if ("notFound" in point) { - return yield* fail( - "automationClick", - automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + }); } return point; }); @@ -1681,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const listeners = yield* Ref.get(pointerEventListenersRef); yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore), + (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)), { discard: true }, ); }); @@ -1695,20 +1950,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], { concurrency: 2, discard: true }, ); - const point = yield* resolveClickPoint(send, input); + const point = yield* resolveClickPoint(tabId, send, input); const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( + tabId, send, "({ width: window.innerWidth, height: window.innerHeight })", true, ); if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { - return yield* fail( - "automationClick", - automationError( - "PreviewAutomationExecutionError", - `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, - ), - ); + return yield* new PreviewAutomationCoordinatesOutsideViewportError({ + tabId, + x: point.x, + y: point.y, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }); } const moveSequence = yield* nextCounter(pointerSequenceRef); const moveCreatedAt = yield* currentIso; @@ -1756,15 +2012,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( + tabId: string, send: SendCommand, input: PreviewAutomationTypeInput, ) { const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); - const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null; + if (locator) yield* ensurePlaywrightInjected(tabId, send); + const locatorJson = locator + ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator) + : null; const result = yield* evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1784,23 +2044,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationType", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "type", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if ("notFound" in result) { - return yield* fail( - "automationType", - automationError( - "PreviewAutomationExecutionError", - locator - ? `No element matches locator ${locator}.` - : "No element is focused in the preview.", - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "type", + tabId, + ...automationSelectorDiagnostics(input), + }); } }); @@ -1810,10 +2067,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function send: SendCommand, ) { yield* send("Runtime.enable"); - yield* focusAutomationTarget(send, input); + yield* focusAutomationTarget(tabId, send, input); yield* send("Input.insertText", { text: input.text }); - const textJson = yield* encodeJson("automationType.encodeText", input.text); + const textJson = yield* encodeJson( + { operation: "automationType.encodeText", tabId }, + input.text, + ); yield* evaluateWithDebugger( + tabId, send, `(() => { const element = document.activeElement; @@ -1881,13 +2142,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { yield* send("Runtime.enable"); const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); + if (locator) yield* ensurePlaywrightInjected(tabId, send); const locatorJson = locator - ? yield* encodeJson("automationScroll.encodeLocator", locator) + ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator) : null; const result = yield* evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( + tabId, send, `(() => { try { @@ -1902,21 +2164,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationScroll", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "scroll", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if ("notFound" in result) { - return yield* fail( - "automationScroll", - automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ), - ); + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "scroll", + tabId, + ...automationSelectorDiagnostics(input), + }); } }); @@ -1931,24 +2192,26 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( - function* (input: PreviewAutomationEvaluateInput, send: SendCommand) { + function* (tabId: string, input: PreviewAutomationEvaluateInput, send: SendCommand) { yield* send("Runtime.enable"); const value = yield* evaluateWithDebugger( + tabId, send, input.expression, input.returnByValue ?? true, input.awaitPromise ?? true, ); - const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); - if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { - return yield* fail( - "automationEvaluate", - automationError( - "PreviewAutomationResultTooLargeError", - `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, - { maximumBytes: MAX_EVALUATION_BYTES }, - ), - ); + const serialized = yield* encodeJson( + { operation: "automationEvaluate.encodeResult", tabId }, + value, + ); + const actualBytes = Buffer.byteLength(serialized, "utf8"); + if (actualBytes > MAX_EVALUATION_BYTES) { + return yield* new PreviewAutomationResultTooLargeError({ + tabId, + actualBytes, + maximumBytes: MAX_EVALUATION_BYTES, + }); } return value; }, @@ -1960,23 +2223,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); return yield* withControlSession(tabId, wc, "evaluate", (send) => - performAutomationEvaluate(input, send), + performAutomationEvaluate(tabId, input, send), ); }); const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( + tabId: string, input: PreviewAutomationWaitForInput, send: SendCommand, ) { const timeoutMs = input.timeoutMs ?? 15_000; yield* send("Runtime.enable"); const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); + if (locator) yield* ensurePlaywrightInjected(tabId, send); const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ - locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), - input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null), + locator + ? encodeJson({ operation: "automationWaitFor.encodeLocator", tabId }, locator) + : Effect.succeed(null), + input.text + ? encodeJson({ operation: "automationWaitFor.encodeText", tabId }, input.text) + : Effect.succeed(null), input.urlIncludes - ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) + ? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes) : Effect.succeed(null), ]); const deadline = (yield* currentMillis) + timeoutMs; @@ -1984,6 +2252,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const result = yield* evaluateWithDebugger< { matched: boolean } | { invalidSelector: true; message: string } >( + tabId, send, `(() => { try { @@ -2002,23 +2271,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function true, ); if ("invalidSelector" in result) { - return yield* fail( - "automationWaitFor", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "waitFor", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); } if (result.matched) return; yield* Effect.sleep(100); } - return yield* fail( - "automationWaitFor", - automationError( - "PreviewAutomationTimeoutError", - `Preview condition did not match within ${timeoutMs}ms.`, - ), - ); + return yield* new PreviewAutomationTimeoutError({ + tabId, + timeoutMs, + }); }); const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( @@ -2027,7 +2294,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "waitFor", (send) => - performAutomationWaitFor(input, send), + performAutomationWaitFor(tabId, input, send), ); }); @@ -2035,23 +2302,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function artifactPath: string, ) { const resolvedPath = yield* resolveArtifactPath(artifactPath); - yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath)); + yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () => + shell.showItemInFolder(resolvedPath), + ); }); const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* ( artifactPath: string, ) { const resolvedPath = yield* resolveArtifactPath(artifactPath); - const image = yield* attempt("copyArtifactToClipboard.load", () => - nativeImage.createFromPath(resolvedPath), + const image = yield* attempt( + { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath }, + () => nativeImage.createFromPath(resolvedPath), ); if (image.isEmpty()) { - return yield* fail( - "copyArtifactToClipboard", - new Error("Preview artifact could not be loaded as an image."), - ); + return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath }); } - yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image)); + yield* attempt({ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () => + clipboard.writeImage(image), + ); }); const subscribe = ( @@ -2123,142 +2392,360 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); -export class PreviewTabNotFoundError extends Error { - readonly tabId: string; - constructor(tabId: string) { - super(`Preview tab not found: ${tabId}`); - this.name = "PreviewTabNotFoundError"; - this.tabId = tabId; +export class PreviewTabNotFoundError extends Schema.TaggedErrorClass()( + "PreviewTabNotFoundError", + { tabId: Schema.String }, +) { + override get message(): string { + return `Preview tab not found: ${this.tabId}`; } } -export class PreviewWebContentsNotFoundError extends Error { - readonly tabId: string; - readonly webContentsId: number; - constructor(tabId: string, webContentsId: number) { - super(`WebContents ${webContentsId} not found for preview tab ${tabId}`); - this.name = "PreviewWebContentsNotFoundError"; - this.tabId = tabId; - this.webContentsId = webContentsId; +export class PreviewWebContentsNotFoundError extends Schema.TaggedErrorClass()( + "PreviewWebContentsNotFoundError", + { tabId: Schema.String, webContentsId: Schema.Number }, +) { + override get message(): string { + return `WebContents ${this.webContentsId} not found for preview tab ${this.tabId}`; } } -export class PreviewWebviewNotInitializedError extends Error { - readonly tabId: string; - constructor(tabId: string) { - super(`Preview tab "${tabId}" has no webview registered`); - this.name = "PreviewWebviewNotInitializedError"; - this.tabId = tabId; +export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass()( + "PreviewWebviewNotInitializedError", + { tabId: Schema.String }, +) { + override get message(): string { + return `Preview tab "${this.tabId}" has no webview registered`; } } -export class PreviewManagerError extends Data.TaggedError("PreviewManagerError")<{ - readonly operation: string; - readonly cause: unknown; -}> { - override get message() { - return `Desktop preview operation failed: ${this.operation}`; +export class PreviewOperationError extends Schema.TaggedErrorClass()( + "PreviewOperationError", + { + operation: Schema.String, + tabId: Schema.optional(Schema.String), + webContentsId: Schema.optional(Schema.Number), + artifactPath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + static toTimelineMessage(error: PreviewOperationError): string { + return error.cause instanceof Error ? error.cause.message : String(error.cause); + } + + override get message(): string { + const context = [ + this.tabId === undefined ? undefined : `tab ${this.tabId}`, + this.webContentsId === undefined ? undefined : `WebContents ${this.webContentsId}`, + this.artifactPath === undefined ? undefined : `artifact ${this.artifactPath}`, + ].filter((value): value is string => value !== undefined); + return `Desktop preview operation failed: ${this.operation}${context.length === 0 ? "" : ` (${context.join(", ")})`}`; } } -export interface PreviewManagerShape { - readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; - readonly getBrowserSession: (scope?: string) => Effect.Effect; - readonly isBrowserPartition: (partition: string) => boolean; - readonly createTab: (tabId: string) => Effect.Effect; - readonly closeTab: (tabId: string) => Effect.Effect; - readonly registerWebview: ( - tabId: string, - webContentsId: number, - ) => Effect.Effect; - readonly navigate: (tabId: string, url: string) => Effect.Effect; - readonly goBack: (tabId: string) => Effect.Effect; - readonly goForward: (tabId: string) => Effect.Effect; - readonly refresh: (tabId: string) => Effect.Effect; - readonly zoomIn: (tabId: string) => Effect.Effect; - readonly zoomOut: (tabId: string) => Effect.Effect; - readonly resetZoom: (tabId: string) => Effect.Effect; - readonly hardReload: (tabId: string) => Effect.Effect; - readonly openDevTools: (tabId: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; - readonly getBrowserPartition: (scope?: string) => Effect.Effect; - readonly setAnnotationTheme: ( - theme: DesktopPreviewAnnotationTheme, - ) => Effect.Effect; - readonly pickElement: ( - tabId: string, - ) => Effect.Effect; - readonly cancelPickElement: (tabId: string) => Effect.Effect; - readonly captureScreenshot: ( - tabId: string, - ) => Effect.Effect; - readonly revealArtifact: (path: string) => Effect.Effect; - readonly copyArtifactToClipboard: (path: string) => Effect.Effect; - readonly startRecording: (tabId: string) => Effect.Effect; - readonly stopRecording: (tabId: string) => Effect.Effect; - readonly saveRecording: ( - tabId: string, - mimeType: string, - data: Uint8Array, - ) => Effect.Effect; - readonly automationStatus: ( - tabId: string, - ) => Effect.Effect; - readonly automationSnapshot: ( - tabId: string, - ) => Effect.Effect; - readonly automationClick: ( - tabId: string, - input: PreviewAutomationClickInput, - ) => Effect.Effect; - readonly automationType: ( - tabId: string, - input: PreviewAutomationTypeInput, - ) => Effect.Effect; - readonly automationPress: ( - tabId: string, - input: PreviewAutomationPressInput, - ) => Effect.Effect; - readonly automationScroll: ( - tabId: string, - input: PreviewAutomationScrollInput, - ) => Effect.Effect; - readonly automationEvaluate: ( - tabId: string, - input: PreviewAutomationEvaluateInput, - ) => Effect.Effect; - readonly automationWaitFor: ( - tabId: string, - input: PreviewAutomationWaitForInput, - ) => Effect.Effect; - readonly subscribeStateChanges: (listener: Listener) => Effect.Effect; - readonly subscribePointerEvents: ( - listener: PointerEventListener, - ) => Effect.Effect; - readonly subscribeRecordingFrames: ( - listener: RecordingFrameListener, - ) => Effect.Effect; +export const isPreviewOperationError = Schema.is(PreviewOperationError); + +export class PreviewArtifactPathOutsideDirectoryError extends Schema.TaggedErrorClass()( + "PreviewArtifactPathOutsideDirectoryError", + { + artifactPath: Schema.String, + artifactDirectory: Schema.String, + }, +) { + override get message(): string { + return `Preview artifact path ${this.artifactPath} is outside ${this.artifactDirectory}`; + } +} + +export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass()( + "PreviewArtifactImageLoadError", + { artifactPath: Schema.String }, +) { + override get message(): string { + return `Preview artifact could not be loaded as an image: ${this.artifactPath}`; + } +} + +export class PreviewRecordingAlreadyActiveError extends Schema.TaggedErrorClass()( + "PreviewRecordingAlreadyActiveError", + { + requestedTabId: Schema.String, + activeTabId: Schema.String, + }, +) { + override get message(): string { + return `Cannot record preview tab ${this.requestedTabId} while tab ${this.activeTabId} is already recording`; + } +} + +export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass()( + "PreviewAutomationDevToolsOpenError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return `Close preview DevTools before using agent browser control for WebContents ${this.webContentsId}`; + } +} + +export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedErrorClass()( + "PreviewAutomationDebuggerAttachedError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return `Preview control cannot attach to WebContents ${this.webContentsId} because another debugger owns it`; + } +} + +export class PreviewAutomationEvaluationError extends Schema.TaggedErrorClass()( + "PreviewAutomationEvaluationError", + { + tabId: Schema.String, + detailKind: PreviewAutomationEvaluationDetailKind, + detailLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + static toTimelineMessage(error: PreviewAutomationEvaluationError): string { + return previewAutomationEvaluationDetail(error.cause).detail ?? error.message; + } + + override get message(): string { + return `Preview JavaScript evaluation failed in tab ${this.tabId}`; + } +} + +export class PreviewAutomationTargetNotFoundError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetNotFoundError", + { + operation: Schema.String, + tabId: Schema.String, + selectorKind: PreviewAutomationSelectorKind, + selectorLength: Schema.optionalKey(Schema.Number), + }, +) { + override get message(): string { + const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); + return `Preview automation ${this.operation} could not find ${target} in tab ${this.tabId}`; + } +} + +export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedErrorClass()( + "PreviewAutomationCoordinatesOutsideViewportError", + { + tabId: Schema.String, + x: Schema.Number, + y: Schema.Number, + viewportWidth: Schema.Number, + viewportHeight: Schema.Number, + }, +) { + override get message(): string { + return `Click coordinates (${this.x}, ${this.y}) are outside the ${this.viewportWidth}x${this.viewportHeight} preview viewport for tab ${this.tabId}`; + } +} + +export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass()( + "PreviewAutomationInvalidSelectorError", + { + operation: Schema.String, + tabId: Schema.String, + selectorKind: PreviewAutomationSelectorKind, + selectorLength: Schema.optionalKey(Schema.Number), + reasonLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + static toTimelineMessage(error: PreviewAutomationInvalidSelectorError): string { + if (typeof error.cause !== "object" || error.cause === null) return error.message; + const reason = (error.cause as Record)["message"]; + return typeof reason === "string" && reason.length > 0 ? reason : error.message; + } + + get detail(): { + readonly selectorKind: PreviewAutomationSelectorKind; + readonly selectorLength?: number; + } { + return { + selectorKind: this.selectorKind, + ...(this.selectorLength === undefined ? {} : { selectorLength: this.selectorLength }), + }; + } + + override get message(): string { + const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength); + return `Preview automation ${this.operation} rejected ${target} in tab ${this.tabId}`; + } +} + +export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()( + "PreviewAutomationResultTooLargeError", + { + tabId: Schema.String, + actualBytes: Schema.Number, + maximumBytes: Schema.Number, + }, +) { + get detail(): { readonly maximumBytes: number } { + return { maximumBytes: this.maximumBytes }; + } + + override get message(): string { + return `Preview evaluation result in tab ${this.tabId} was ${this.actualBytes} bytes; maximum is ${this.maximumBytes} bytes`; + } +} + +export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationTimeoutError", + { + tabId: Schema.String, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Preview condition did not match within ${this.timeoutMs}ms in tab ${this.tabId}`; + } +} + +export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass()( + "PreviewAutomationControlInterruptedError", + { + operation: Schema.String, + tabId: Schema.String, + webContentsId: Schema.Number, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} was interrupted by human input in tab ${this.tabId}`; + } } -export class PreviewManager extends Context.Service()( - "@t3tools/desktop/preview/Manager/PreviewManager", -) {} +export const PreviewManagerError = Schema.Union([ + PreviewTabNotFoundError, + PreviewWebContentsNotFoundError, + PreviewWebviewNotInitializedError, + PreviewOperationError, + PreviewArtifactPathOutsideDirectoryError, + PreviewArtifactImageLoadError, + PreviewRecordingAlreadyActiveError, + PreviewAutomationDevToolsOpenError, + PreviewAutomationDebuggerAttachedError, + PreviewAutomationEvaluationError, + PreviewAutomationTargetNotFoundError, + PreviewAutomationCoordinatesOutsideViewportError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationResultTooLargeError, + PreviewAutomationTimeoutError, + PreviewAutomationControlInterruptedError, +]); +export type PreviewManagerError = typeof PreviewManagerError.Type; + +export const isPreviewManagerError = Schema.is(PreviewManagerError); +export const isPreviewAutomationControlInterruptedError = Schema.is( + PreviewAutomationControlInterruptedError, +); +export const isPreviewAutomationEvaluationError = Schema.is(PreviewAutomationEvaluationError); +export const isPreviewAutomationInvalidSelectorError = Schema.is( + PreviewAutomationInvalidSelectorError, +); + +export class PreviewManager extends Context.Service< + PreviewManager, + { + readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; + readonly getBrowserSession: (scope?: string) => Effect.Effect; + readonly isBrowserPartition: (partition: string) => boolean; + readonly createTab: (tabId: string) => Effect.Effect; + readonly closeTab: (tabId: string) => Effect.Effect; + readonly registerWebview: ( + tabId: string, + webContentsId: number, + ) => Effect.Effect; + readonly navigate: (tabId: string, url: string) => Effect.Effect; + readonly goBack: (tabId: string) => Effect.Effect; + readonly goForward: (tabId: string) => Effect.Effect; + readonly refresh: (tabId: string) => Effect.Effect; + readonly zoomIn: (tabId: string) => Effect.Effect; + readonly zoomOut: (tabId: string) => Effect.Effect; + readonly resetZoom: (tabId: string) => Effect.Effect; + readonly hardReload: (tabId: string) => Effect.Effect; + readonly openDevTools: (tabId: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; + readonly getBrowserPartition: (scope?: string) => Effect.Effect; + readonly setAnnotationTheme: ( + theme: DesktopPreviewAnnotationTheme, + ) => Effect.Effect; + readonly pickElement: ( + tabId: string, + ) => Effect.Effect; + readonly cancelPickElement: (tabId: string) => Effect.Effect; + readonly captureScreenshot: ( + tabId: string, + ) => Effect.Effect; + readonly revealArtifact: (path: string) => Effect.Effect; + readonly copyArtifactToClipboard: (path: string) => Effect.Effect; + readonly startRecording: (tabId: string) => Effect.Effect; + readonly stopRecording: (tabId: string) => Effect.Effect; + readonly saveRecording: ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) => Effect.Effect; + readonly automationStatus: ( + tabId: string, + ) => Effect.Effect; + readonly automationSnapshot: ( + tabId: string, + ) => Effect.Effect; + readonly automationClick: ( + tabId: string, + input: PreviewAutomationClickInput, + ) => Effect.Effect; + readonly automationType: ( + tabId: string, + input: PreviewAutomationTypeInput, + ) => Effect.Effect; + readonly automationPress: ( + tabId: string, + input: PreviewAutomationPressInput, + ) => Effect.Effect; + readonly automationScroll: ( + tabId: string, + input: PreviewAutomationScrollInput, + ) => Effect.Effect; + readonly automationEvaluate: ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) => Effect.Effect; + readonly automationWaitFor: ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) => Effect.Effect; + readonly subscribeStateChanges: (listener: Listener) => Effect.Effect; + readonly subscribePointerEvents: ( + listener: PointerEventListener, + ) => Effect.Effect; + readonly subscribeRecordingFrames: ( + listener: RecordingFrameListener, + ) => Effect.Effect; + } +>()("@t3tools/desktop/preview/Manager/PreviewManager") {} -const make = Effect.gen(function* PreviewManagerMake() { +export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; const operations = yield* makeNativeOperations(environment.browserArtifactsDir); - const browserSessionEffect = ( - operation: string, - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); return PreviewManager.of({ setMainWindow: operations.setMainWindow, getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { - return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); + return yield* browserSession + .getSession(scope) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), + ), + ); }), isBrowserPartition: browserSession.isPartition, createTab: operations.createTab, @@ -2274,13 +2761,29 @@ const make = Effect.gen(function* PreviewManagerMake() { hardReload: operations.hardReload, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { - yield* browserSessionEffect("clearCookies", browserSession.clearCookies()); + yield* browserSession + .clearCookies() + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), + ), + ); }), clearCache: Effect.fn("PreviewManager.clearCache")(function* () { - yield* browserSessionEffect("clearCache", browserSession.clearCache()); + yield* browserSession + .clearCache() + .pipe( + Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), + ); }), getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { - return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope)); + return yield* browserSession + .getPartition(scope) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), + ), + ); }), setAnnotationTheme: operations.setAnnotationTheme, pickElement: operations.pickElement, diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts index 33915dba0be..cd7fee1e3c7 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -3,10 +3,14 @@ import * as Effect from "effect/Effect"; import { describe, expect } from "vite-plus/test"; import { + extractPlaywrightInjectedRuntimeSource, playwrightInjectedRuntimeInstallExpression, playwrightInjectedRuntimeSource, } from "./PlaywrightInjectedRuntime.ts"; +const bundleWithSourceLiteral = (literal: string): string => + `const source3 = ${literal};\n }\n});`; + describe("playwright injected runtime", () => { effectIt.effect("extracts the pinned runtime from playwright-core", () => Effect.gen(function* () { @@ -23,4 +27,54 @@ describe("playwright injected runtime", () => { expect(expression).toContain('testIdAttributeName":"data-testid'); }), ); + + effectIt.effect("reports a missing source marker without an artificial cause", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource("const source = 'missing';", "/tmp/coreBundle.js"), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceMarkerNotFoundError", + bundlePath: "/tmp/coreBundle.js", + marker: "source3 = ", + }); + expect("cause" in error).toBe(false); + }), + ); + + effectIt.effect("keeps source validation metadata cause-free", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource( + bundleWithSourceLiteral('"short"'), + "/tmp/coreBundle.js", + ), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceValidationError", + bundlePath: "/tmp/coreBundle.js", + actualType: "string", + actualLength: 5, + minimumLength: 100_000, + }); + expect("cause" in error).toBe(false); + }), + ); + + effectIt.effect("preserves the source evaluation cause", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + extractPlaywrightInjectedRuntimeSource(bundleWithSourceLiteral("("), "/tmp/coreBundle.js"), + ); + + expect(error).toMatchObject({ + _tag: "PlaywrightSourceEvaluationError", + bundlePath: "/tmp/coreBundle.js", + timeoutMs: 1_000, + cause: expect.objectContaining({ name: "SyntaxError" }), + }); + }), + ); }); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts index 1a4dce14f87..ff1531f08f3 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -1,68 +1,183 @@ // @effect-diagnostics nodeBuiltinImport:off - Extracts Playwright's installed Node bundle for browser injection. -import { readFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { runInNewContext } from "node:vm"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeVM from "node:vm"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -const require = createRequire(import.meta.url); +const require = NodeModule.createRequire(import.meta.url); const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const PLAYWRIGHT_PACKAGE_SPECIFIER = "playwright-core/package.json"; +const PLAYWRIGHT_SOURCE_MARKER = "source3 = "; +const PLAYWRIGHT_SOURCE_TERMINATOR = ";\n }\n});"; +const PLAYWRIGHT_SOURCE_MINIMUM_LENGTH = 100_000; +const PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS = 1_000; +const PLAYWRIGHT_SDK_LANGUAGE = "javascript"; +const PLAYWRIGHT_BROWSER_NAME = "chromium"; -export class PlaywrightInjectedRuntimeError extends Data.TaggedError( - "PlaywrightInjectedRuntimeError", -)<{ - readonly operation: string; - readonly cause: unknown; -}> { - override get message() { - return `Playwright injected runtime operation failed: ${this.operation}`; +export class PlaywrightPackageResolveError extends Schema.TaggedErrorClass()( + "PlaywrightPackageResolveError", + { + specifier: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve Playwright package: ${this.specifier}`; + } +} + +export class PlaywrightCoreBundleReadError extends Schema.TaggedErrorClass()( + "PlaywrightCoreBundleReadError", + { + bundlePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read Playwright core bundle: ${this.bundlePath}`; + } +} + +export class PlaywrightSourceMarkerNotFoundError extends Schema.TaggedErrorClass()( + "PlaywrightSourceMarkerNotFoundError", + { + bundlePath: Schema.String, + marker: Schema.String, + }, +) { + override get message(): string { + return `Playwright injected runtime marker ${JSON.stringify(this.marker)} was not found in ${this.bundlePath}`; + } +} + +export class PlaywrightSourceTerminatorNotFoundError extends Schema.TaggedErrorClass()( + "PlaywrightSourceTerminatorNotFoundError", + { + bundlePath: Schema.String, + terminator: Schema.String, + }, +) { + override get message(): string { + return `Playwright injected runtime terminator ${JSON.stringify(this.terminator)} was not found in ${this.bundlePath}`; + } +} + +export class PlaywrightSourceEvaluationError extends Schema.TaggedErrorClass()( + "PlaywrightSourceEvaluationError", + { + bundlePath: Schema.String, + timeoutMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to evaluate the Playwright injected runtime literal from ${this.bundlePath} within ${this.timeoutMs}ms`; } } -const fail = (operation: string, cause: unknown) => - new PlaywrightInjectedRuntimeError({ operation, cause }); +export class PlaywrightSourceValidationError extends Schema.TaggedErrorClass()( + "PlaywrightSourceValidationError", + { + bundlePath: Schema.String, + actualType: Schema.String, + actualLength: Schema.NullOr(Schema.Number), + minimumLength: Schema.Number, + }, +) { + override get message(): string { + const actual = + this.actualLength === null + ? this.actualType + : `${this.actualType} with ${this.actualLength} characters`; + return `Playwright injected runtime from ${this.bundlePath} was ${actual}; expected a string with at least ${this.minimumLength} characters`; + } +} + +export class PlaywrightOptionsEncodeError extends Schema.TaggedErrorClass()( + "PlaywrightOptionsEncodeError", + { + sdkLanguage: Schema.String, + browserName: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode ${this.browserName} Playwright injected runtime options for ${this.sdkLanguage}`; + } +} + +export const PlaywrightInjectedRuntimeError = Schema.Union([ + PlaywrightPackageResolveError, + PlaywrightCoreBundleReadError, + PlaywrightSourceMarkerNotFoundError, + PlaywrightSourceTerminatorNotFoundError, + PlaywrightSourceEvaluationError, + PlaywrightSourceValidationError, + PlaywrightOptionsEncodeError, +]); +export type PlaywrightInjectedRuntimeError = typeof PlaywrightInjectedRuntimeError.Type; + +export const extractPlaywrightInjectedRuntimeSource = Effect.fn( + "PlaywrightInjectedRuntime.extractSource", +)(function* (coreBundle: string, bundlePath: string) { + const start = coreBundle.indexOf(PLAYWRIGHT_SOURCE_MARKER); + if (start < 0) { + return yield* new PlaywrightSourceMarkerNotFoundError({ + bundlePath, + marker: PLAYWRIGHT_SOURCE_MARKER, + }); + } + const literalStart = start + PLAYWRIGHT_SOURCE_MARKER.length; + const literalEnd = coreBundle.indexOf(PLAYWRIGHT_SOURCE_TERMINATOR, literalStart); + if (literalEnd < 0) { + return yield* new PlaywrightSourceTerminatorNotFoundError({ + bundlePath, + terminator: PLAYWRIGHT_SOURCE_TERMINATOR, + }); + } + const literal = coreBundle.slice(literalStart, literalEnd); + const source = yield* Effect.try({ + try: () => + NodeVM.runInNewContext(literal, Object.create(null), { + timeout: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS, + }), + catch: (cause) => + new PlaywrightSourceEvaluationError({ + bundlePath, + timeoutMs: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS, + cause, + }), + }); + if (typeof source !== "string" || source.length < PLAYWRIGHT_SOURCE_MINIMUM_LENGTH) { + return yield* new PlaywrightSourceValidationError({ + bundlePath, + actualType: typeof source, + actualLength: typeof source === "string" ? source.length : null, + minimumLength: PLAYWRIGHT_SOURCE_MINIMUM_LENGTH, + }); + } + return source; +}); export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")( function* () { const packageJsonPath = yield* Effect.try({ - try: () => require.resolve("playwright-core/package.json"), - catch: (cause) => fail("resolvePackage", cause), + try: () => require.resolve(PLAYWRIGHT_PACKAGE_SPECIFIER), + catch: (cause) => + new PlaywrightPackageResolveError({ + specifier: PLAYWRIGHT_PACKAGE_SPECIFIER, + cause, + }), }); + const bundlePath = NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js"); const coreBundle = yield* Effect.tryPromise({ - try: () => readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"), - catch: (cause) => fail("readCoreBundle", cause), - }); - const marker = "source3 = "; - const start = coreBundle.indexOf(marker); - if (start < 0) { - return yield* fail( - "findSourceMarker", - new Error("Playwright injected runtime marker was not found."), - ); - } - const literalStart = start + marker.length; - const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); - if (literalEnd < 0) { - return yield* fail( - "findSourceTerminator", - new Error("Playwright injected runtime terminator was not found."), - ); - } - const literal = coreBundle.slice(literalStart, literalEnd); - const source = yield* Effect.try({ - try: () => runInNewContext(literal, Object.create(null), { timeout: 1_000 }), - catch: (cause) => fail("evaluateSourceLiteral", cause), + try: () => NodeFSP.readFile(bundlePath, "utf8"), + catch: (cause) => new PlaywrightCoreBundleReadError({ bundlePath, cause }), }); - if (typeof source !== "string" || source.length < 100_000) { - return yield* fail( - "validateSource", - new Error("Playwright injected runtime extraction returned invalid source."), - ); - } - return source; + return yield* extractPlaywrightInjectedRuntimeSource(coreBundle, bundlePath); }, ); @@ -72,14 +187,23 @@ export const playwrightInjectedRuntimeInstallExpression = Effect.fn( const source = yield* playwrightInjectedRuntimeSource(); const options = yield* encodeUnknownJson({ isUnderTest: false, - sdkLanguage: "javascript", + sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE, testIdAttributeName: "data-testid", stableRafCount: 1, - browserName: "chromium", + browserName: PLAYWRIGHT_BROWSER_NAME, shouldPrependErrorPrefix: false, isUtilityWorld: false, customEngines: [], - }).pipe(Effect.mapError((cause) => fail("encodeOptions", cause))); + }).pipe( + Effect.mapError( + (cause) => + new PlaywrightOptionsEncodeError({ + sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE, + browserName: PLAYWRIGHT_BROWSER_NAME, + cause, + }), + ), + ); return `(() => { if (globalThis.__t3PlaywrightInjected) return true; const module = { exports: {} }; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f7..c76ffa8bbda 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -8,11 +8,6 @@ import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import { - DEFAULT_DESKTOP_SETTINGS, - resolveDefaultDesktopSettings, - type DesktopSettings as DesktopSettingsValue, -} from "./DesktopAppSettings.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ @@ -82,20 +77,23 @@ describe("DesktopSettings", () => { withSettings( Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; - assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); - assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); }), ), ); it("defaults packaged nightly builds to the nightly update channel", () => { - assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + assert.deepEqual( + DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), + { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopAppSettings.DesktopSettings, + ); }); it.effect("loads persisted settings and applies semantic updates", () => @@ -116,7 +114,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); const exposure = yield* settings.setServerExposureMode("local-only"); assert.isTrue(exposure.changed); @@ -137,6 +135,27 @@ describe("DesktopSettings", () => { ), ); + it.effect("reports the failed desktop settings write operation and path", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.desktopSettingsPath, { recursive: true }); + + const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip); + assert.instanceOf(error, DesktopAppSettings.DesktopSettingsWriteError); + assert.equal(error.operation, "replace-settings-file"); + assert.equal(error.path, environment.desktopSettingsPath); + assert.exists(error.cause); + assert.equal( + error.message, + `Desktop settings write failed during replace-settings-file at ${environment.desktopSettingsPath}.`, + ); + }), + ), + ); + it.effect("does not persist no-op semantic updates", () => withSettings( Effect.gen(function* () { @@ -167,7 +186,7 @@ describe("DesktopSettings", () => { yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json"); - assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); }), ), ); @@ -195,7 +214,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), ), ); @@ -234,7 +253,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, ), @@ -256,7 +275,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, ), @@ -277,7 +296,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a54f22fec5b..81aae92f0a3 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -7,13 +7,11 @@ import { import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; 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"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -63,32 +61,44 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet changed, }); -export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop settings: ${this.cause.message}`; - } -} +const DesktopSettingsWriteOperation = Schema.Literals([ + "create-temporary-file-name", + "encode-document", + "create-directory", + "write-temporary-file", + "replace-settings-file", +]); +type DesktopSettingsWriteOperation = typeof DesktopSettingsWriteOperation.Type; -export interface DesktopAppSettingsShape { - readonly load: Effect.Effect; - readonly get: Effect.Effect; - readonly setServerExposureMode: ( - mode: DesktopServerExposureMode, - ) => Effect.Effect; - readonly setTailscaleServe: (input: { - readonly enabled: boolean; - readonly port: Option.Option; - }) => Effect.Effect; - readonly setUpdateChannel: ( - channel: DesktopUpdateChannel, - ) => Effect.Effect; +export class DesktopSettingsWriteError extends Schema.TaggedErrorClass()( + "DesktopSettingsWriteError", + { + operation: DesktopSettingsWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop settings write failed during ${this.operation} at ${this.path}.`; + } } export class DesktopAppSettings extends Context.Service< DesktopAppSettings, - DesktopAppSettingsShape + { + readonly load: Effect.Effect; + readonly get: Effect.Effect; + readonly setServerExposureMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServe: (input: { + readonly enabled: boolean; + readonly port: Option.Option; + }) => Effect.Effect; + readonly setUpdateChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; + } >()("@t3tools/desktop/settings/DesktopAppSettings") {} export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { @@ -223,77 +233,119 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp readonly settings: DesktopSettings; readonly defaultSettings: DesktopSettings; readonly suffix: string; -}): Effect.fn.Return { +}): Effect.fn.Return { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), + ).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "encode-document", + path: input.settingsPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: input.settingsPath, + cause, + }), + ), ); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.settingsPath); }); -export const layer = Layer.effect( - DesktopAppSettings, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); - - const persist = ( - update: (settings: DesktopSettings) => DesktopSettings, - ): Effect.Effect => - SynchronizedRef.modifyEffect(settingsRef, (settings) => { - const nextSettings = update(settings); - if (nextSettings === settings) { - return Effect.succeed([settingsChange(settings, false), settings] as const); - } - - return crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeSettings({ - fileSystem, - path, - settingsPath: environment.desktopSettingsPath, - settings: nextSettings, - defaultSettings: environment.defaultDesktopSettings, - suffix, - }), - ), - Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })), - Effect.as([settingsChange(nextSettings, true), nextSettings] as const), - ); - }); +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); - return DesktopAppSettings.of({ - get: SynchronizedRef.get(settingsRef), - load: Effect.gen(function* () { - const settings = yield* readSettings( - fileSystem, - environment.desktopSettingsPath, - environment.appVersion, - ); - return yield* SynchronizedRef.setAndGet(settingsRef, settings); - }).pipe(Effect.withSpan("desktop.settings.load")), - setServerExposureMode: (mode) => - persist((settings) => setServerExposureMode(settings, mode)).pipe( - Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), - ), - setTailscaleServe: (input) => - persist((settings) => setTailscaleServe(settings, input)).pipe( - Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), + const persist = ( + update: (settings: DesktopSettings) => DesktopSettings, + ): Effect.Effect => + SynchronizedRef.modifyEffect(settingsRef, (settings) => { + const nextSettings = update(settings); + if (nextSettings === settings) { + return Effect.succeed([settingsChange(settings, false), settings] as const); + } + + return crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.desktopSettingsPath, + cause, + }), ), - setUpdateChannel: (channel) => - persist((settings) => setUpdateChannel(settings, channel)).pipe( - Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), + Effect.flatMap((suffix) => + writeSettings({ + fileSystem, + path, + settingsPath: environment.desktopSettingsPath, + settings: nextSettings, + defaultSettings: environment.defaultDesktopSettings, + suffix, + }), ), + Effect.as([settingsChange(nextSettings, true), nextSettings] as const), + ); }); - }), -); + + return DesktopAppSettings.of({ + get: SynchronizedRef.get(settingsRef), + load: Effect.gen(function* () { + const settings = yield* readSettings( + fileSystem, + environment.desktopSettingsPath, + environment.appVersion, + ); + return yield* SynchronizedRef.setAndGet(settingsRef, settings); + }).pipe(Effect.withSpan("desktop.settings.load")), + setServerExposureMode: (mode) => + persist((settings) => setServerExposureMode(settings, mode)).pipe( + Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), + ), + setTailscaleServe: (input) => + persist((settings) => setTailscaleServe(settings, input)).pipe( + Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), + ), + setUpdateChannel: (channel) => + persist((settings) => setUpdateChannel(settings, channel)).pipe( + Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), + ), + }); +}); + +export const layer = Layer.effect(DesktopAppSettings, make); export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) => Layer.effect( diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts new file mode 100644 index 00000000000..5034df44cf7 --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -0,0 +1,129 @@ +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 Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopClientSettings from "./DesktopClientSettings.ts"; + +interface LogRecord { + readonly message: unknown; + readonly annotations: Readonly>; +} + +const baseDir = "/virtual-home"; + +function makeLayer(fileSystemLayer: Layer.Layer) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopClientSettings.layer.pipe( + Layer.provideMerge(Layer.mergeAll(environmentLayer, NodeServices.layer, fileSystemLayer)), + ); +} + +const readWithLogs = (fileSystemLayer: Layer.Layer) => { + const records: Array = []; + const logger = Logger.make(({ fiber, message }) => { + records.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + return { + result: yield* settings.get, + settingsPath: environment.clientSettingsPath, + records, + }; + }).pipe( + Effect.provide( + Layer.mergeAll( + makeLayer(fileSystemLayer), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); +}; + +describe("DesktopClientSettings diagnostics", () => { + it.effect("treats a missing settings file as expected without warning", () => + Effect.gen(function* () { + const result = yield* readWithLogs(FileSystem.layerNoop({})); + + assert.isTrue(Option.isNone(result.result)); + assert.deepEqual(result.records, []); + }), + ); + + it.effect("logs non-missing filesystem failures with the settings path", () => { + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: `${baseDir}/userdata/client-settings.json`, + }); + + return Effect.gen(function* () { + const result = yield* readWithLogs( + FileSystem.layerNoop({ + readFileString: () => Effect.fail(permissionError), + }), + ); + + assert.isTrue(Option.isNone(result.result)); + assert.equal(result.records.length, 1); + assert.deepEqual(result.records[0]?.message, [ + "Could not read desktop client settings.", + permissionError, + ]); + assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); + }); + }); + + it.effect("logs malformed settings documents with the settings path", () => + Effect.gen(function* () { + const result = yield* readWithLogs( + FileSystem.layerNoop({ + readFileString: () => Effect.succeed("{not-json"), + }), + ); + + assert.isTrue(Option.isNone(result.result)); + assert.equal(result.records.length, 1); + const message = result.records[0]?.message; + if (!Array.isArray(message)) { + return assert.fail("expected structured warning arguments"); + } + assert.equal(message[0], "Could not decode desktop client settings."); + const schemaError = message[1]; + if (schemaError === null || typeof schemaError !== "object") { + return assert.fail("expected the schema error in the warning"); + } + assert.equal("_tag" in schemaError ? schemaError._tag : undefined, "SchemaError"); + assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); + }), + ); +}); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 75fb1a20966..c06289d7bbc 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -5,6 +5,7 @@ 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 PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -18,7 +19,6 @@ const clientSettings: ClientSettings = { contextMenuStyle: "default", dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, - diffWordWrap: true, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", @@ -29,13 +29,13 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", + wordWrap: true, }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); const decodeRecordJson = Schema.decodeEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); - function makeLayer(baseDir: string) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -107,6 +107,29 @@ describe("DesktopClientSettings", () => { ), ); + it.effect("reports the failed client settings write operation and path", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.clientSettingsPath, { recursive: true }); + + const error = yield* settings.set(clientSettings).pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError); + assert.equal(error.operation, "replace-settings-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.isString(error.cause.stack); + assert.equal( + error.message, + `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`, + ); + assert.notInclude(error.message, error.cause.message); + }), + ), + ); + it.effect("loads lenient direct client settings documents", () => withClientSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 68d3fdc904a..4ff091e27a2 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -2,13 +2,11 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; 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"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Ref from "effect/Ref"; @@ -27,28 +25,41 @@ const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); const decodeClientSettingsJson = (raw: string): Effect.Effect => decodeLegacyClientSettingsDocumentJson(raw).pipe( Effect.map((document) => document.settings), - Effect.catch(() => decodeClientSettingsJsonValue(raw)), + Effect.catchTags({ + SchemaError: () => decodeClientSettingsJsonValue(raw), + }), ); const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); -export class DesktopClientSettingsWriteError extends Data.TaggedError( +const DesktopClientSettingsWriteOperation = Schema.Literals([ + "create-temporary-file-name", + "encode-document", + "create-directory", + "write-temporary-file", + "replace-settings-file", +]); + +export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass()( "DesktopClientSettingsWriteError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop client settings: ${this.cause.message}`; + { + operation: DesktopClientSettingsWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings write failed during ${this.operation} at ${this.path}.`; } } -export interface DesktopClientSettingsShape { - readonly get: Effect.Effect>; - readonly set: (settings: ClientSettings) => Effect.Effect; -} - export class DesktopClientSettings extends Context.Service< DesktopClientSettings, - DesktopClientSettingsShape + { + readonly get: Effect.Effect>; + readonly set: ( + settings: ClientSettings, + ) => Effect.Effect; + } >()("@t3tools/desktop/settings/DesktopClientSettings") {} const readClientSettings = ( @@ -56,14 +67,29 @@ const readClientSettings = ( settingsPath: string, ): Effect.Effect> => fileSystem.readFileString(settingsPath).pipe( - Effect.option, + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.logWarning("Could not read desktop client settings.", cause).pipe( + Effect.annotateLogs({ settingsPath }), + Effect.as(Option.none()), + ), + }), Effect.flatMap( Option.match({ onNone: () => Effect.succeed(Option.none()), onSome: (raw) => decodeClientSettingsJson(raw).pipe( Effect.map((settings) => Option.some(settings)), - Effect.orElseSucceed(() => Option.none()), + Effect.catchTags({ + SchemaError: (cause) => + Effect.logWarning("Could not decode desktop client settings.", cause).pipe( + Effect.annotateLogs({ settingsPath }), + Effect.as(Option.none()), + ), + }), ), }), ), @@ -75,45 +101,87 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { readonly settingsPath: string; readonly settings: ClientSettings; readonly suffix: string; -}): Effect.fn.Return { +}): Effect.fn.Return { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeClientSettingsJson(input.settings); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.settingsPath); + const encoded = yield* encodeClientSettingsJson(input.settings).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "encode-document", + path: input.settingsPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "replace-settings-file", + path: input.settingsPath, + cause, + }), + ), + ); }); -export const layer = Layer.effect( - DesktopClientSettings, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; - return DesktopClientSettings.of({ - get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( - Effect.withSpan("desktop.clientSettings.get"), - ), - set: (settings) => - crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeClientSettings({ - fileSystem, - path, - settingsPath: environment.clientSettingsPath, - settings, - suffix, + return DesktopClientSettings.of({ + get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( + Effect.withSpan("desktop.clientSettings.get"), + ), + set: (settings) => + crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.clientSettingsPath, + cause, }), - ), - Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })), - Effect.withSpan("desktop.clientSettings.set"), ), - }); - }), -); + Effect.flatMap((suffix) => + writeClientSettings({ + fileSystem, + path, + settingsPath: environment.clientSettingsPath, + settings, + suffix, + }), + ), + Effect.withSpan("desktop.clientSettings.set"), + ), + }); +}); + +export const layer = Layer.effect(DesktopClientSettings, make); export const layerTest = (initialSettings: Option.Option = Option.none()) => Layer.effect( diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index d1d37b96e11..ec70308b3d3 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -5,6 +5,7 @@ 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 PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -34,10 +35,15 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({ version: Schema.Number, records: Schema.Array(Schema.Unknown), }); +const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString( + SavedEnvironmentRegistryDocumentProbe, +); const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( - Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe), + SavedEnvironmentRegistryDocumentProbeJson, +); +const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect( + SavedEnvironmentRegistryDocumentProbeJson, ); - function makeSafeStorageLayer(input: { readonly available: boolean; readonly availabilityError?: unknown; @@ -80,7 +86,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, - } satisfies ElectronSafeStorage.ElectronSafeStorageShape); + } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } function makeLayer( @@ -91,6 +97,7 @@ function makeLayer( readonly encryptError?: unknown; readonly decryptError?: unknown; }, + fileSystemLayer: Layer.Layer = NodeServices.layer, ) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -108,18 +115,20 @@ function makeLayer( ), ); - return DesktopSavedEnvironments.layer.pipe( - Layer.provideMerge(environmentLayer), - Layer.provideMerge( - makeSafeStorageLayer({ - available: options?.availableSecretStorage ?? true, - availabilityError: options?.availabilityError, - encryptError: options?.encryptError, - decryptError: options?.decryptError, - }), - ), - Layer.provideMerge(NodeServices.layer), + const safeStorageLayer = makeSafeStorageLayer({ + available: options?.availableSecretStorage ?? true, + availabilityError: options?.availabilityError, + encryptError: options?.encryptError, + decryptError: options?.decryptError, + }); + const dependencies = Layer.mergeAll( + environmentLayer, + safeStorageLayer, + NodeServices.layer, + fileSystemLayer, ); + + return DesktopSavedEnvironments.layer.pipe(Layer.provideMerge(dependencies)); } const withSavedEnvironments = ( @@ -215,6 +224,36 @@ describe("DesktopSavedEnvironments", () => { ), ); + it.effect("reports invalid saved secret encoding without exposing the secret", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({ + version: 1, + records: [{ ...savedRegistryRecord, encryptedBearerToken: "%%%" }], + }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`); + + const error = yield* savedEnvironments + .getSecret(savedRegistryRecord.environmentId) + .pipe(Effect.flip); + assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError); + assert.equal(error.environmentId, savedRegistryRecord.environmentId); + assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); + assert.equal(error.field, "encryptedBearerToken"); + assert.exists(error.cause); + assert.equal( + error.message, + `Failed to decode encryptedBearerToken for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, + ); + assert.notInclude(error.message, "%%%"); + }), + ), + ); + it.effect("returns false when writing secrets while encryption is unavailable", () => withSavedEnvironments( Effect.gen(function* () { @@ -232,10 +271,11 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("surfaces typed safe storage availability failures", () => { + it.effect("adds saved-environment context to safe storage availability failures", () => { const cause = new Error("safe storage unavailable"); return withSavedEnvironments( Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; yield* savedEnvironments.setRegistry([savedRegistryRecord]); @@ -246,8 +286,22 @@ describe("DesktopSavedEnvironments", () => { }) .pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); - assert.equal(error.cause, cause); + assert.instanceOf( + error, + DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError, + ); + assert.equal(error.operation, "check-encryption-availability"); + assert.equal(error.environmentId, savedRegistryRecord.environmentId); + assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); + assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); + const availabilityError = + error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError; + assert.strictEqual(availabilityError.cause, cause); + assert.equal( + error.message, + `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, + ); + assert.notEqual(error.message, availabilityError.message); }), { availabilityError: cause }, ); @@ -272,6 +326,26 @@ describe("DesktopSavedEnvironments", () => { ), ); + it.effect("removes saved environment metadata and its embedded secret atomically", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }); + + yield* savedEnvironments.removeEnvironment(savedRegistryRecord.environmentId); + + assert.deepEqual(yield* savedEnvironments.getRegistry, []); + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + it.effect("treats empty saved environment documents as empty", () => withSavedEnvironments( Effect.gen(function* () { @@ -289,7 +363,7 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("treats malformed saved environment documents as empty", () => + it.effect("surfaces malformed saved environment documents", () => withSavedEnvironments( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -298,14 +372,99 @@ describe("DesktopSavedEnvironments", () => { yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); - assert.deepEqual(yield* savedEnvironments.getRegistry, []); - assert.isTrue( - Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip); + assert.instanceOf( + registryError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, + ); + assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); + assert.exists(registryError.cause); + const secretError = yield* savedEnvironments + .getSecret(savedRegistryRecord.environmentId) + .pipe(Effect.flip); + assert.instanceOf( + secretError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, + ); + const mutationError = yield* savedEnvironments + .setRegistry([savedRegistryRecord]) + .pipe(Effect.flip); + assert.instanceOf( + mutationError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, ); }), ), ); + it.effect("reports saved environment filesystem reads separately from document decoding", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + const registryPath = `${baseDir}/userdata/saved-environments.json`; + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: registryPath, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.fail(permissionError), + }), + ); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( + Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)), + ); + + const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); + assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); + assert.equal(error.registryPath, registryPath); + assert.strictEqual(error.cause, permissionError); + assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports the failed saved environment write operation and path", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: `${baseDir}/userdata`, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: baseFileSystem.readFileString, + makeDirectory: () => Effect.fail(permissionError), + }), + ); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( + Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)), + ); + + const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip); + assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError); + assert.equal(error.operation, "create-directory"); + assert.equal(error.path, `${baseDir}/userdata`); + assert.strictEqual(error.cause, permissionError); + assert.equal( + error.message, + `Desktop saved-environment write failed during create-directory at ${baseDir}/userdata.`, + ); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("returns false when writing a secret without metadata", () => withSavedEnvironments( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 531b50ba73b..bdda7f9c738 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -2,14 +2,12 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Ref from "effect/Ref"; @@ -72,56 +70,126 @@ const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( SavedEnvironmentRegistryDocumentJson, ); -export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( +const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([ + "create-temporary-file-name", + "encode-registry", + "create-directory", + "write-temporary-file", + "replace-registry-file", +]); + +const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([ + "check-encryption-availability", + "encrypt-secret", + "decrypt-secret", +]); + +export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsWriteError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop saved environments: ${this.cause.message}`; + { + operation: DesktopSavedEnvironmentsWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop saved-environment write failed during ${this.operation} at ${this.path}.`; + } +} + +export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( + "DesktopSavedEnvironmentsReadError", + { + registryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read desktop saved environments at ${this.registryPath}.`; + } +} + +export class DesktopSavedEnvironmentsDocumentDecodeError extends Schema.TaggedErrorClass()( + "DesktopSavedEnvironmentsDocumentDecodeError", + { + registryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode desktop saved environments at ${this.registryPath}.`; } } -export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( +export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentSecretDecodeError", -)<{ - readonly cause: Encoding.EncodingError; -}> { - override get message() { - return "Failed to decode desktop saved environment secret."; + { + environmentId: Schema.String, + registryPath: Schema.String, + field: Schema.Literal("encryptedBearerToken"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode ${this.field} for environment ${this.environmentId} at ${this.registryPath}.`; + } +} + +export class DesktopSavedEnvironmentSecretProtectionError extends Schema.TaggedErrorClass()( + "DesktopSavedEnvironmentSecretProtectionError", + { + operation: DesktopSavedEnvironmentSecretProtectionOperation, + environmentId: Schema.String, + registryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop saved-environment secret protection failed during ${this.operation} for environment ${this.environmentId} at ${this.registryPath}.`; } } +export type DesktopSavedEnvironmentsReadRegistryError = + | DesktopSavedEnvironmentsReadError + | DesktopSavedEnvironmentsDocumentDecodeError; + +export type DesktopSavedEnvironmentsMutationError = + | DesktopSavedEnvironmentsReadRegistryError + | DesktopSavedEnvironmentsWriteError; + export type DesktopSavedEnvironmentsGetSecretError = + | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError; + | DesktopSavedEnvironmentSecretProtectionError; export type DesktopSavedEnvironmentsSetSecretError = - | DesktopSavedEnvironmentsWriteError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError; - -export interface DesktopSavedEnvironmentsShape { - readonly getRegistry: Effect.Effect; - readonly setRegistry: ( - records: readonly PersistedSavedEnvironmentRecord[], - ) => Effect.Effect; - readonly getSecret: ( - environmentId: string, - ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; - readonly setSecret: (input: { - readonly environmentId: string; - readonly secret: string; - }) => Effect.Effect; - readonly removeSecret: ( - environmentId: string, - ) => Effect.Effect; -} + | DesktopSavedEnvironmentsMutationError + | DesktopSavedEnvironmentSecretProtectionError; export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, - DesktopSavedEnvironmentsShape + { + readonly getRegistry: Effect.Effect< + readonly PersistedSavedEnvironmentRecord[], + DesktopSavedEnvironmentsReadRegistryError + >; + readonly setRegistry: ( + records: readonly PersistedSavedEnvironmentRecord[], + ) => Effect.Effect; + readonly removeEnvironment: ( + environmentId: string, + ) => Effect.Effect; + readonly getSecret: ( + environmentId: string, + ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; + readonly setSecret: (input: { + readonly environmentId: string; + readonly secret: string; + }) => Effect.Effect; + readonly removeSecret: ( + environmentId: string, + ) => Effect.Effect; + } >()("@t3tools/desktop/settings/DesktopSavedEnvironments") {} function toPersistedSavedEnvironmentRecord( @@ -176,18 +244,31 @@ function normalizeSavedEnvironmentRegistryDocument( function readRegistryDocument( fileSystem: FileSystem.FileSystem, registryPath: string, -): Effect.Effect { +): Effect.Effect { return fileSystem.readFileString(registryPath).pipe( - Effect.option, - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed({ version: 1, records: [] }), - onSome: (raw) => - decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new DesktopSavedEnvironmentsReadError({ + registryPath, + cause: error, + }), + ), + ), + Effect.flatMap((raw) => + raw === null + ? Effect.succeed({ version: 1, records: [] }) + : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( Effect.map(normalizeSavedEnvironmentRegistryDocument), - Effect.orElseSucceed(() => ({ version: 1, records: [] })), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsDocumentDecodeError({ + registryPath, + cause, + }), + ), ), - }), ), ); } @@ -199,13 +280,49 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry readonly registryPath: string; readonly document: SavedEnvironmentRegistryDocument; readonly suffix: string; - }): Effect.fn.Return { + }): Effect.fn.Return { const directory = input.path.dirname(input.registryPath); const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.registryPath); + const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "encode-registry", + path: input.registryPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.registryPath).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "replace-registry-file", + path: input.registryPath, + cause, + }), + ), + ); }, ); @@ -231,129 +348,213 @@ function preserveExistingSecrets( } function decodeSecretBytes( + environmentId: string, + registryPath: string, encoded: string, ): Effect.Effect { return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( - Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretDecodeError({ + environmentId, + registryPath, + field: "encryptedBearerToken", + cause, + }), + ), ); } -export const layer = Layer.effect( - DesktopSavedEnvironments, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const crypto = yield* Crypto.Crypto; - - const writeDocument = (document: SavedEnvironmentRegistryDocument) => - crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeRegistryDocument({ - fileSystem, - path, - registryPath: environment.savedEnvironmentRegistryPath, - document, - suffix, +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const crypto = yield* Crypto.Crypto; + + const writeDocument = (document: SavedEnvironmentRegistryDocument) => + crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "create-temporary-file-name", + path: environment.savedEnvironmentRegistryPath, + cause, }), - ), - Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })), - ); - - return DesktopSavedEnvironments.of({ - getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( - Effect.map((document) => - document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), - ), - Effect.withSpan("desktop.savedEnvironments.getRegistry"), ), - setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { - const currentDocument = yield* readRegistryDocument( + Effect.flatMap((suffix) => + writeRegistryDocument({ fileSystem, - environment.savedEnvironmentRegistryPath, - ); - yield* writeDocument(preserveExistingSecrets(currentDocument, records)); - }), - getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - const encoded = Option.fromNullishOr( - document.records.find((record) => record.environmentId === environmentId) - ?.encryptedBearerToken, - ); - if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { - return Option.none(); - } - - const secretBytes = yield* decodeSecretBytes(encoded.value); - return Option.some(yield* safeStorage.decryptString(secretBytes)); - }), - setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { - const { environmentId, secret } = input; - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - - if (!(yield* safeStorage.isEncryptionAvailable)) { - return false; - } - - const encryptedBearerToken = Encoding.encodeBase64( - yield* safeStorage.encryptString(secret), - ); - let found = false; - const nextDocument: SavedEnvironmentRegistryDocument = { - version: document.version, - records: document.records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - - found = true; - return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); - }), - }; + path, + registryPath: environment.savedEnvironmentRegistryPath, + document, + suffix, + }), + ), + ); - if (found) { - yield* writeDocument(nextDocument); - } - return found; - }), - removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { + return DesktopSavedEnvironments.of({ + getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( + Effect.map((document) => + document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), + ), + Effect.withSpan("desktop.savedEnvironments.getRegistry"), + ), + setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { + const currentDocument = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + yield* writeDocument(preserveExistingSecrets(currentDocument, records)); + }), + removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")( + function* (environmentId) { yield* Effect.annotateCurrentSpan({ environmentId }); const document = yield* readRegistryDocument( fileSystem, environment.savedEnvironmentRegistryPath, ); - if ( - !document.records.some( - (record) => - record.environmentId === environmentId && record.encryptedBearerToken !== undefined, - ) - ) { + if (!document.records.some((record) => record.environmentId === environmentId)) { return; } yield* writeDocument({ version: document.version, - records: document.records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - return toPersistedSavedEnvironmentRecord(record); - }), + records: document.records.filter((record) => record.environmentId !== environmentId), }); - }), - }); - }), -); + }, + ), + getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + const encoded = Option.fromNullishOr( + document.records.find((record) => record.environmentId === environmentId) + ?.encryptedBearerToken, + ); + if (Option.isNone(encoded)) { + return Option.none(); + } + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "check-encryption-availability", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ); + if (!encryptionAvailable) { + return Option.none(); + } + + const secretBytes = yield* decodeSecretBytes( + environmentId, + environment.savedEnvironmentRegistryPath, + encoded.value, + ); + return Option.some( + yield* safeStorage.decryptString(secretBytes).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "decrypt-secret", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ), + ); + }), + setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { + const { environmentId, secret } = input; + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "check-encryption-availability", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ); + if (!encryptionAvailable) { + return false; + } + + const encryptedBearerToken = Encoding.encodeBase64( + yield* safeStorage.encryptString(secret).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "encrypt-secret", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ), + ); + let found = false; + const nextDocument: SavedEnvironmentRegistryDocument = { + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + + found = true; + return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); + }), + }; + + if (found) { + yield* writeDocument(nextDocument); + } + return found; + }), + removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + if ( + !document.records.some( + (record) => + record.environmentId === environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + yield* writeDocument({ + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + return toPersistedSavedEnvironmentRecord(record); + }), + }); + }), + }); +}); + +export const layer = Layer.effect(DesktopSavedEnvironments, make); export const layerTest = (input?: { readonly records?: readonly PersistedSavedEnvironmentRecord[]; @@ -368,6 +569,18 @@ export const layerTest = (input?: { return DesktopSavedEnvironments.of({ getRegistry: Ref.get(recordsRef), setRegistry: (records) => Ref.set(recordsRef, records), + removeEnvironment: (environmentId) => + Ref.update(recordsRef, (records) => + records.filter((record) => record.environmentId !== environmentId), + ).pipe( + Effect.andThen( + Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.delete(environmentId); + return nextSecrets; + }), + ), + ), getSecret: (environmentId) => Ref.get(secretsRef).pipe( Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))), diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 897e7336a24..7ec0ab80ae7 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,15 +1,23 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; const textEncoder = new TextEncoder(); +const isDesktopShellEnvironmentCommandError = Schema.is( + DesktopShellEnvironment.DesktopShellEnvironmentCommandError, +); + function envOutput(values: Readonly>): string { return Object.entries(values) .flatMap(([name, value]) => [ @@ -59,16 +67,21 @@ function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly handler: (command: ChildProcess.Command) => string; + readonly failure?: PlatformError.PlatformError; }) { const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ platform: input.platform, - } as DesktopEnvironment.DesktopEnvironmentShape), + } as DesktopEnvironment.DesktopEnvironment["Service"]), ); const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), + ChildProcessSpawner.make((command) => + input.failure === undefined + ? Effect.succeed(makeProcess(input.handler(command))) + : Effect.fail(input.failure), + ), ); const program = Effect.gen(function* () { @@ -229,4 +242,44 @@ describe("DesktopShellEnvironment", () => { ); }), ); + + it.effect("logs command failures with safe probe context and the exact cause", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/bash", + PATH: "/usr/bin", + }; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "/bin/bash", + }); + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return runShellEnvironment({ + env, + platform: "linux", + handler: () => "", + failure: cause, + }).pipe( + Effect.andThen( + Effect.sync(() => { + const errors = messages + .flatMap((message) => (Array.isArray(message) ? message : [message])) + .filter(isDesktopShellEnvironmentCommandError); + assert.lengthOf(errors, 1); + assert.equal(errors[0]?.probe, "login-shell"); + assert.equal(errors[0]?.executable, "bash"); + assert.equal(errors[0]?.argumentCount, 2); + assert.notProperty(errors[0] ?? {}, "args"); + assert.equal(errors[0]?.cause, cause); + assert.notInclude(errors[0]?.message ?? "", cause.message); + }), + ), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ); + }); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 13ac35b6297..8219f18b7a5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,7 +3,9 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -19,13 +21,49 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } -export interface DesktopShellEnvironmentShape { - readonly installIntoProcess: Effect.Effect; +const DesktopShellEnvironmentProbe = Schema.Literals([ + "login-shell", + "launchctl-path", + "powershell-profile", + "powershell-no-profile", +]); +type DesktopShellEnvironmentProbe = typeof DesktopShellEnvironmentProbe.Type; + +const desktopShellEnvironmentCommandFields = { + probe: DesktopShellEnvironmentProbe, + executable: Schema.String, + argumentCount: Schema.Number, +}; + +export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass()( + "DesktopShellEnvironmentCommandError", + { + ...desktopShellEnvironmentCommandFields, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop shell environment ${this.probe} probe (${this.executable}) failed.`; + } +} + +export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErrorClass()( + "DesktopShellEnvironmentCommandTimeoutError", + { + ...desktopShellEnvironmentCommandFields, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Desktop shell environment ${this.probe} probe (${this.executable}) timed out after ${this.timeoutMs}ms.`; + } } export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, - DesktopShellEnvironmentShape + { + readonly installIntoProcess: Effect.Effect; + } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} const LOGIN_SHELL_ENV_NAMES = [ @@ -128,6 +166,18 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; +const executableName = (command: string): string => command.split(/[\\/]/u).at(-1) ?? command; + +const logShellEnvironmentCommandError = ( + error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError, +) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + component: "desktop-shell-environment", + error, + }), + ); + const capturePosixEnvironmentCommand = (names: ReadonlyArray) => names .map((name) => { @@ -176,13 +226,14 @@ const extractEnvironment = (output: string, names: ReadonlyArray): Envir }; const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { + readonly probe: DesktopShellEnvironmentProbe; readonly command: string; readonly args: ReadonlyArray; readonly timeout: Duration.Duration; readonly shell?: boolean; }): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* spawner + const output = yield* spawner .string( ChildProcess.make(input.command, input.args, { shell: input.shell ?? false, @@ -194,10 +245,33 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( }), ) .pipe( + Effect.mapError( + (cause) => + new DesktopShellEnvironmentCommandError({ + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, + cause, + }), + ), + Effect.catchTags({ + DesktopShellEnvironmentCommandError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as("")), + }), Effect.timeoutOption(input.timeout), - Effect.map(Option.getOrElse(() => "")), - Effect.orElseSucceed(() => ""), ); + if (Option.isSome(output)) { + return output.value; + } + + const error = new DesktopShellEnvironmentCommandTimeoutError({ + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, + timeoutMs: Duration.toMillis(input.timeout), + }); + yield* logShellEnvironmentCommandError(error); + return ""; }); const readLoginShellEnvironment = ( @@ -207,16 +281,14 @@ const readLoginShellEnvironment = ( names.length === 0 ? Effect.succeed({}) : runCommandOutput({ + probe: "login-shell", command: shell, args: ["-ilc", capturePosixEnvironmentCommand(names)], timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); -const readLaunchctlPath: Effect.Effect< - Option.Option, - never, - ChildProcessSpawner.ChildProcessSpawner -> = runCommandOutput({ +const readLaunchctlPath = runCommandOutput({ + probe: "launchctl-path", command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, @@ -239,6 +311,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn for (const command of WINDOWS_SHELL_CANDIDATES) { const output = yield* runCommandOutput({ + probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, timeout: LOGIN_SHELL_TIMEOUT, @@ -336,20 +409,20 @@ const installShellEnvironment = ( return Effect.void; }; -export const layer = Layer.effect( - DesktopShellEnvironment, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return DesktopShellEnvironment.of({ - installIntoProcess: installShellEnvironment({ - env: process.env, - platform: environment.platform, - userShell: Option.none(), - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), - ), - }); - }), -); +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = + installShellEnvironment({ + env: process.env, + platform: environment.platform, + userShell: Option.none(), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + ); + + return DesktopShellEnvironment.of({ installIntoProcess }); +}); + +export const layer = Layer.effect(DesktopShellEnvironment, make); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 77c86be39d2..1fe2b86aae7 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -19,6 +19,21 @@ function makeTempHomeDir() { } describe("sshEnvironment", () => { + it("keeps prompt presentation diagnostics distinct from the legacy wrapper message", () => { + const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({ + requestId: "prompt-1", + destination: "devbox", + operation: "send-prompt-request", + cause: new Error("renderer send failed"), + }); + + assert.equal(cause.message, "Failed to present SSH password prompt for devbox."); + assert.equal( + DesktopSshEnvironment.toSshPasswordPromptError(cause).message, + "T3 Code window is not available for SSH authentication.", + ); + }); + it("treats password prompt timeouts as cancellable authentication prompts", () => { assert.equal( DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( @@ -104,7 +119,6 @@ describe("sshEnvironment", () => { Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { request: () => Effect.die("unexpected password prompt request"), resolve: () => Effect.die("unexpected password prompt resolution"), - cancelPending: () => Effect.void, }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 595d3bea304..31e84ae995e 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -4,11 +4,7 @@ import type { DesktopSshEnvironmentTarget, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; -import { - SshPasswordPrompt, - type SshPasswordPromptShape, - type SshPasswordRequest, -} from "@t3tools/ssh/auth"; +import * as SshAuth from "@t3tools/ssh/auth"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { SshCommandError, @@ -19,14 +15,14 @@ import { SshPasswordPromptError, SshReadinessError, } from "@t3tools/ssh/errors"; -import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import * as SshTunnel from "@t3tools/ssh/tunnel"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; @@ -52,27 +48,25 @@ export type DesktopSshEnvironmentError = | DesktopSshEnvironmentDiscoverError | DesktopSshEnvironmentOperationError; -export interface DesktopSshEnvironmentShape { - readonly discoverHosts: (input?: { - readonly homeDir?: string; - }) => Effect.Effect; - readonly ensureEnvironment: ( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, - ) => Effect.Effect; - readonly disconnectEnvironment: ( - target: DesktopSshEnvironmentTarget, - ) => Effect.Effect; -} - export class DesktopSshEnvironment extends Context.Service< DesktopSshEnvironment, - DesktopSshEnvironmentShape + { + readonly discoverHosts: (input?: { + readonly homeDir?: string; + }) => Effect.Effect; + readonly ensureEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) => Effect.Effect; + readonly disconnectEnvironment: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + } >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: Effect.Effect; + readonly resolveCliRunner?: Effect.Effect; } function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { @@ -88,27 +82,53 @@ export function isDesktopSshPasswordPromptCancellation( ); } +function unexpectedPasswordPromptError(error: never): never { + throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`); +} + +export function toSshPasswordPromptError( + cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError, +): SshPasswordPromptError { + let message: string; + switch (cause._tag) { + case "DesktopSshPromptRequestIdGenerationError": + message = "Secure randomness is unavailable."; + break; + case "DesktopSshPromptWindowUnavailableError": + case "DesktopSshPromptPresentationError": + message = "T3 Code window is not available for SSH authentication."; + break; + case "DesktopSshPromptTimedOutError": + message = `SSH authentication timed out for ${cause.destination}.`; + break; + case "DesktopSshPromptCancelledError": + message = `SSH authentication cancelled for ${cause.destination}.`; + break; + case "DesktopSshPromptWindowClosedError": + message = "SSH authentication was cancelled because the app window closed."; + break; + case "DesktopSshPromptServiceStoppedError": + message = "SSH password prompt service stopped."; + break; + default: + return unexpectedPasswordPromptError(cause); + } + return new SshPasswordPromptError({ message, cause }); +} + const makePasswordPrompt = ( - prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape, -): SshPasswordPromptShape => ({ + prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPrompts["Service"], +): SshAuth.SshPasswordPrompt["Service"] => ({ isAvailable: true, - request: (request: SshPasswordRequest) => - prompts.request(request).pipe( - Effect.mapError( - (cause) => - new SshPasswordPromptError({ - message: cause.message, - cause, - }), - ), - ), + request: (request: SshAuth.SshPasswordRequest) => + prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), }); -const make = Effect.gen(function* () { - const manager = yield* SshEnvironmentManager; +export const make = Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; const runtimeContext = yield* Effect.context(); - const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts)); + const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ discoverHosts: (input) => @@ -120,7 +140,7 @@ const make = Effect.gen(function* () { manager .ensureEnvironment(target, ensureOptions) .pipe( - Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.ensureEnvironment"), ), @@ -128,7 +148,7 @@ const make = Effect.gen(function* () { manager .disconnectEnvironment(target) .pipe( - Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.disconnectEnvironment"), ), @@ -138,7 +158,7 @@ const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshEnvironmentManager.layer({ + SshTunnel.SshEnvironmentManager.layer({ ...(options.resolveCliPackageSpec === undefined ? {} : { resolveCliPackageSpec: options.resolveCliPackageSpec }), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index 080a2fe465d..5ec7dd65d1e 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -9,7 +9,7 @@ import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; +import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; interface SentMessage { @@ -17,7 +17,13 @@ interface SentMessage { readonly args: readonly unknown[]; } -function makeTestWindow() { +function makeTestWindow( + options: { + readonly isDestroyedError?: unknown; + readonly isMinimizedError?: unknown; + readonly sendError?: unknown; + } = {}, +) { const listeners = new Map void>>(); const sentMessages: SentMessage[] = []; let destroyed = false; @@ -26,8 +32,18 @@ function makeTestWindow() { let focused = false; const window = { - isDestroyed: () => destroyed, - isMinimized: () => minimized, + isDestroyed: () => { + if (options.isDestroyedError !== undefined) { + throw options.isDestroyedError; + } + return destroyed; + }, + isMinimized: () => { + if (options.isMinimizedError !== undefined) { + throw options.isMinimizedError; + } + return minimized; + }, restore: () => { restored = true; minimized = false; @@ -45,7 +61,11 @@ function makeTestWindow() { }, webContents: { send: (channel: string, ...args: readonly unknown[]) => { - sentMessages.push({ channel, args }); + const message = { channel, args }; + sentMessages.push(message); + if (options.sendError !== undefined) { + throw options.sendError; + } }, }, }; @@ -55,6 +75,7 @@ function makeTestWindow() { sentMessages, isRestored: () => restored, isFocused: () => focused, + closedListenerCount: () => listeners.get("closed")?.size ?? 0, close: () => { destroyed = true; const closedListeners = [...(listeners.get("closed") ?? [])]; @@ -107,11 +128,12 @@ describe("DesktopSshPasswordPrompts", () => { }) .pipe(Effect.forkScoped); + yield* Effect.yieldNow; yield* Effect.yieldNow; assert.equal(testWindow.sentMessages.length, 1); const sent = testWindow.sentMessages[0]; assert.ok(sent); - assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL); + assert.equal(sent.channel, SSH_PASSWORD_PROMPT_CHANNEL); const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; assert.equal(request.destination, "devbox"); assert.equal(testWindow.isRestored(), true); @@ -143,4 +165,85 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(error.destination, "devbox"); }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); }); + + it.effect("cleans up a prompt that fails during renderer delivery", () => { + const cause = new Error("renderer unavailable"); + const testWindow = makeTestWindow({ sendError: cause }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const error = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); + assert.equal(error.operation, "send-prompt-request"); + assert.equal(error.destination, "devbox"); + const requestId = error.requestId; + if (requestId === null) { + assert.fail("renderer delivery failures must retain their request id"); + } + assert.equal(testWindow.closedListenerCount(), 0); + + const resolveError = yield* prompts + .resolve({ requestId, password: "secret" }) + .pipe(Effect.flip); + assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("keeps a submitted password when a later presentation step fails", () => { + const testWindow = makeTestWindow({ + isMinimizedError: new Error("failed to read minimized state"), + }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const requestFiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + const sent = testWindow.sentMessages[0]; + assert.ok(sent); + const request = sent.args[0] as { readonly requestId: string }; + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + const password = yield* Fiber.join(requestFiber); + + assert.equal(password, "secret"); + assert.equal(testWindow.isFocused(), false); + assert.equal(testWindow.closedListenerCount(), 0); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("classifies a failed initial window availability check", () => { + const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const error = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); + assert.equal(error.operation, "check-window-before-request"); + assert.equal(error.requestId, null); + assert.deepEqual(testWindow.sentMessages, []); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 1d50f9ca325..aa25d8135c7 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -3,7 +3,6 @@ import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contract import type { SshPasswordRequest } from "@t3tools/ssh/auth"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -11,93 +10,155 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; -import * as IpcChannels from "../ipc/channels.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; -const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; type DesktopSshPasswordPromptResolutionInput = typeof DesktopSshPasswordPromptResolutionInputSchema.Type; -export class DesktopSshPromptUnavailableError extends Data.TaggedError( - "DesktopSshPromptUnavailableError", -)<{ - readonly reason: string; -}> { - override get message() { - return this.reason; +const DesktopSshPromptWindowAvailabilityStage = Schema.Literals([ + "before-request", + "before-presentation", + "after-send", + "after-restore", +]); + +const DesktopSshPromptPresentationOperation = Schema.Literals([ + "check-window-before-request", + "check-window-before-presentation", + "register-window-close-listener", + "send-prompt-request", + "check-window-after-send", + "check-window-minimized", + "restore-window", + "check-window-after-restore", + "focus-window", + "remove-window-close-listener", +]); +type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type; + +export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass()( + "DesktopSshPromptRequestIdGenerationError", + { + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Secure randomness is unavailable."; } } -export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError( +export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorClass()( "DesktopSshPromptWindowUnavailableError", -)<{ - readonly destination: string; -}> { - override get message() { - return WINDOW_UNAVAILABLE_MESSAGE; + { + destination: Schema.String, + requestId: Schema.NullOr(Schema.String), + stage: DesktopSshPromptWindowAvailabilityStage, + }, +) { + override get message(): string { + const request = this.requestId === null ? "before a request id was assigned" : this.requestId; + return `T3 Code window is unavailable during ${this.stage} for SSH authentication to ${this.destination} (request: ${request}).`; } } -export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ - readonly requestId: string; - readonly destination: string; - readonly cause: unknown; -}> { - override get message() { - return WINDOW_UNAVAILABLE_MESSAGE; +export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass()( + "DesktopSshPromptPresentationError", + { + requestId: Schema.NullOr(Schema.String), + destination: Schema.String, + operation: DesktopSshPromptPresentationOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to present SSH password prompt for ${this.destination}.`; } } -export class DesktopSshPromptTimedOutError extends Data.TaggedError( +export class DesktopSshPromptTimedOutError extends Schema.TaggedErrorClass()( "DesktopSshPromptTimedOutError", -)<{ - readonly requestId: string; - readonly destination: string; -}> { - override get message() { + { + requestId: Schema.String, + destination: Schema.String, + }, +) { + override get message(): string { return `SSH authentication timed out for ${this.destination}.`; } } -export class DesktopSshPromptCancelledError extends Data.TaggedError( +export class DesktopSshPromptCancelledError extends Schema.TaggedErrorClass()( "DesktopSshPromptCancelledError", -)<{ - readonly requestId: string; - readonly destination: string; - readonly reason: string; -}> { - override get message() { - return this.reason; + { + requestId: Schema.String, + destination: Schema.String, + }, +) { + override get message(): string { + return `SSH authentication cancelled for ${this.destination}.`; } } -export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError( +export class DesktopSshPromptWindowClosedError extends Schema.TaggedErrorClass()( + "DesktopSshPromptWindowClosedError", + { + requestId: Schema.String, + destination: Schema.String, + }, +) { + override get message(): string { + return "SSH authentication was cancelled because the app window closed."; + } +} + +export class DesktopSshPromptServiceStoppedError extends Schema.TaggedErrorClass()( + "DesktopSshPromptServiceStoppedError", + { + requestId: Schema.String, + destination: Schema.String, + }, +) { + override get message(): string { + return "SSH password prompt service stopped."; + } +} + +export class DesktopSshPromptInvalidRequestIdError extends Schema.TaggedErrorClass()( "DesktopSshPromptInvalidRequestIdError", -)<{ - readonly requestId: string; -}> { - override get message() { + { + requestId: Schema.String, + }, +) { + override get message(): string { return "Invalid SSH password prompt id."; } } -export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{ - readonly requestId: string; -}> { - override get message() { +export class DesktopSshPromptExpiredError extends Schema.TaggedErrorClass()( + "DesktopSshPromptExpiredError", + { + requestId: Schema.String, + }, +) { + override get message(): string { return "SSH password prompt expired. Try connecting again."; } } export type DesktopSshPasswordPromptRequestError = - | DesktopSshPromptUnavailableError + | DesktopSshPromptRequestIdGenerationError | DesktopSshPromptWindowUnavailableError - | DesktopSshPromptSendError + | DesktopSshPromptPresentationError | DesktopSshPromptTimedOutError - | DesktopSshPromptCancelledError; + | DesktopSshPromptCancelledError + | DesktopSshPromptWindowClosedError + | DesktopSshPromptServiceStoppedError; export type DesktopSshPasswordPromptResolveError = | DesktopSshPromptInvalidRequestIdError @@ -107,28 +168,28 @@ export type DesktopSshPasswordPromptError = | DesktopSshPasswordPromptRequestError | DesktopSshPasswordPromptResolveError; -export function isDesktopSshPasswordPromptCancellation( - error: unknown, -): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError { - return ( - error instanceof DesktopSshPromptCancelledError || - error instanceof DesktopSshPromptTimedOutError - ); -} +export const DesktopSshPasswordPromptCancellation = Schema.Union([ + DesktopSshPromptCancelledError, + DesktopSshPromptWindowClosedError, + DesktopSshPromptServiceStoppedError, + DesktopSshPromptTimedOutError, +]); +export type DesktopSshPasswordPromptCancellation = typeof DesktopSshPasswordPromptCancellation.Type; -export interface DesktopSshPasswordPromptsShape { - readonly request: ( - request: SshPasswordRequest, - ) => Effect.Effect; - readonly resolve: ( - input: DesktopSshPasswordPromptResolutionInput, - ) => Effect.Effect; - readonly cancelPending: (reason: string) => Effect.Effect; -} +export const isDesktopSshPasswordPromptCancellation = Schema.is( + DesktopSshPasswordPromptCancellation, +); export class DesktopSshPasswordPrompts extends Context.Service< DesktopSshPasswordPrompts, - DesktopSshPasswordPromptsShape + { + readonly request: ( + request: SshPasswordRequest, + ) => Effect.Effect; + readonly resolve: ( + input: DesktopSshPasswordPromptResolutionInput, + ) => Effect.Effect; + } >()("@t3tools/desktop/ssh/DesktopSshPasswordPrompts") {} interface PendingSshPasswordPrompt { @@ -137,7 +198,7 @@ interface PendingSshPasswordPrompt { readonly deferred: Deferred.Deferred; } -interface LayerOptions { +export interface DesktopSshPasswordPromptsOptions { readonly passwordPromptTimeoutMs?: number; } @@ -161,14 +222,16 @@ const failPending = ( error: DesktopSshPasswordPromptRequestError, ) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); -const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) { +export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( + options: DesktopSshPasswordPromptsOptions = {}, +) { const electronWindow = yield* ElectronWindow.ElectronWindow; const crypto = yield* Crypto.Crypto; const pendingRef = yield* Ref.make(new Map()); const passwordPromptTimeoutMs = options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; - const cancelPending = (reason: string): Effect.Effect => + const cancelPending = () => Ref.getAndSet(pendingRef, new Map()).pipe( Effect.flatMap((pending) => Effect.forEach( @@ -176,10 +239,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La (entry) => failPending( entry, - new DesktopSshPromptCancelledError({ + new DesktopSshPromptServiceStoppedError({ requestId: entry.requestId, destination: entry.destination, - reason, }), ), { discard: true }, @@ -188,13 +250,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La Effect.asVoid, ); - yield* Effect.addFinalizer(() => - cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), - ); + yield* Effect.addFinalizer(() => cancelPending().pipe(Effect.ignore)); - const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* ( - input: DesktopSshPasswordPromptResolutionInput, - ): Effect.fn.Return { + const resolve: DesktopSshPasswordPrompts["Service"]["resolve"] = Effect.fn( + "desktop.sshPasswordPrompts.resolve", + )(function* (input) { const requestId = input.requestId.trim(); if (requestId.length === 0) { return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); @@ -212,7 +272,6 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La new DesktopSshPromptCancelledError({ requestId, destination: entry.destination, - reason: `SSH authentication cancelled for ${entry.destination}.`, }), ); return; @@ -221,19 +280,43 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); }); - const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* ( - input: SshPasswordRequest, - ): Effect.fn.Return { + const request: DesktopSshPasswordPrompts["Service"]["request"] = Effect.fn( + "desktop.sshPasswordPrompts.request", + )(function* (input) { const window = yield* electronWindow.main; - if (Option.isNone(window) || window.value.isDestroyed()) { + if (Option.isNone(window)) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId: null, + stage: "before-request", + }); + } + + const unavailableBeforeRequest = yield* Effect.try({ + try: () => window.value.isDestroyed(), + catch: (cause) => + new DesktopSshPromptPresentationError({ + requestId: null, + destination: input.destination, + operation: "check-window-before-request", + cause, + }), + }); + if (unavailableBeforeRequest) { return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId: null, + stage: "before-request", }); } const requestId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( - () => new DesktopSshPromptUnavailableError({ reason: "Secure randomness is unavailable." }), + (cause) => + new DesktopSshPromptRequestIdGenerationError({ + destination: input.destination, + cause, + }), ), ); const now = yield* DateTime.now; @@ -267,10 +350,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La onSome: (pending) => failPending( pending, - new DesktopSshPromptCancelledError({ + new DesktopSshPromptWindowClosedError({ requestId, destination: input.destination, - reason: "SSH authentication was cancelled because the app window closed.", }), ), }), @@ -278,11 +360,25 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La ), ); }; - const cleanup = Effect.sync(() => { + const runPresentationOperation = ( + operation: DesktopSshPromptPresentationOperation, + evaluate: () => A, + ) => + Effect.try({ + try: evaluate, + catch: (cause) => + new DesktopSshPromptPresentationError({ + requestId, + destination: input.destination, + operation, + cause, + }), + }); + const cleanup = runPresentationOperation("remove-window-close-listener", () => { if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); + }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid); const waitForPassword = Deferred.await(deferred).pipe( Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), Effect.flatMap( @@ -298,40 +394,80 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La }), ), ); + const preferSubmittedPassword = (error: DesktopSshPasswordPromptRequestError) => + Deferred.poll(deferred).pipe( + Effect.flatMap( + Option.match({ + onSome: (completion) => completion, + onNone: () => + Ref.get(pendingRef).pipe( + Effect.flatMap((entries) => + entries.has(requestId) ? Effect.fail(error) : Deferred.await(deferred), + ), + ), + }), + ), + ); - return yield* Effect.try({ - try: () => { - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); - } - window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + return yield* Effect.gen(function* () { + const unavailableBeforePresentation = yield* runPresentationOperation( + "check-window-before-presentation", + () => window.value.isDestroyed(), + ); + if (unavailableBeforePresentation) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId, + stage: "before-presentation", + }); + } + yield* runPresentationOperation("register-window-close-listener", () => + window.value.once("closed", cancelOnWindowClosed), + ); + return yield* Effect.gen(function* () { + yield* runPresentationOperation("send-prompt-request", () => + window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest), + ); + yield* Effect.yieldNow; + const unavailableAfterSend = yield* runPresentationOperation( + "check-window-after-send", + () => window.value.isDestroyed(), + ); + if (unavailableAfterSend) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId, + stage: "after-send", + }); } - if (window.value.isMinimized()) { - window.value.restore(); + const minimized = yield* runPresentationOperation("check-window-minimized", () => + window.value.isMinimized(), + ); + if (minimized) { + yield* runPresentationOperation("restore-window", () => window.value.restore()); } - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + const unavailableAfterRestore = yield* runPresentationOperation( + "check-window-after-restore", + () => window.value.isDestroyed(), + ); + if (unavailableAfterRestore) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId, + stage: "after-restore", + }); } - window.value.focus(); - }, - catch: (cause) => - new DesktopSshPromptSendError({ - requestId, - destination: input.destination, - cause, - }), - }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); + yield* runPresentationOperation("focus-window", () => window.value.focus()); + return yield* waitForPassword; + }).pipe(Effect.catch(preferSubmittedPassword)); + }).pipe(Effect.ensuring(cleanup)); }); return DesktopSshPasswordPrompts.of({ request, resolve, - cancelPending, }); }); -export const layer = (options: LayerOptions = {}) => +export const layer = (options: DesktopSshPasswordPromptsOptions = {}) => Layer.effect(DesktopSshPasswordPrompts, make(options)); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 34d18f11a77..4c90afb2a12 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -7,7 +7,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; @@ -24,6 +27,9 @@ interface UpdatesHarnessOptions { void, ElectronUpdater.ElectronUpdaterCheckForUpdatesError >; + readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; + readonly setDisableDifferentialDownload?: Effect.Effect; + readonly stopBackend?: Effect.Effect; readonly env?: Record; } @@ -67,7 +73,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), - setDisableDifferentialDownload: () => Effect.void, + setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), @@ -83,7 +89,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); }), ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdaterShape); + } satisfies ElectronUpdater.ElectronUpdater["Service"]); const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), @@ -99,11 +105,11 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { }), destroyAll: Effect.void, syncAllAppearance: () => Effect.void, - } satisfies ElectronWindow.ElectronWindowShape); + } satisfies ElectronWindow.ElectronWindow["Service"]); const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, - stop: () => Effect.void, + stop: () => options.stopBackend ?? Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed({ desiredRunning: false, @@ -138,12 +144,23 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ), ); + const setUpdateChannelError = options.setUpdateChannelError; + const settingsLayer = setUpdateChannelError + ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.fail(setUpdateChannelError), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) + : DesktopAppSettings.layer; + const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(settingsLayer), Layer.provideMerge( DesktopConfig.layerTest({ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, @@ -175,6 +192,45 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { } describe("DesktopUpdates", () => { + it("preserves complete causes for update poller and event failures", () => { + const cause = Cause.combine( + Cause.fail(new Error("updater failed")), + Cause.die(new Error("updater defect")), + ); + const pollerError = new DesktopUpdates.DesktopUpdatePollerError({ + poller: "startup", + cause, + }); + const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({ + event: "download-progress", + cause, + }); + const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({ + operation: "download", + cause, + }); + const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({ + action: "install", + cause, + }); + + assert.strictEqual(pollerError.cause, cause); + assert.equal(pollerError.poller, "startup"); + assert.equal(pollerError.message, "Desktop update startup poller failed."); + assert.strictEqual(eventError.cause, cause); + assert.equal(eventError.event, "download-progress"); + assert.equal(eventError.message, "Failed to handle desktop update download-progress event."); + assert.strictEqual(reportedError.cause, cause); + assert.equal(reportedError.operation, "download"); + assert.equal(reportedError.message, "Desktop updater download operation reported an error."); + assert.strictEqual(unexpectedActionError.cause, cause); + assert.equal(unexpectedActionError.action, "install"); + assert.equal( + unexpectedActionError.message, + "Desktop update install action failed unexpectedly.", + ); + }); + it.effect("configures the updater and runs startup checks on the test clock", () => { const harness = makeHarness(); @@ -222,6 +278,178 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("keeps raw updater event failures out of update state", () => { + const harness = makeHarness(); + const cause = new Error( + "request failed for https://user:secret@example.com/update?token=secret", + ); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("error", cause); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "error"); + assert.equal(state.message, "Desktop updater background operation reported an error."); + assert.notInclude(state.message ?? "", "secret"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("logs bounded updater failure context without exposing the cause", () => { + const cause = new Error( + "request failed for https://user:secret@example.com/update?token=secret", + ); + const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ + channel: null, + cause, + }); + const harness = makeHarness({ checkForUpdates: Effect.fail(updaterError) }); + const loggedAnnotations: Array> = []; + const logger = Logger.make(({ fiber }) => { + const annotations = fiber.getRef(References.CurrentLogAnnotations); + if (annotations.errorTag === "ElectronUpdaterCheckForUpdatesError") { + loggedAnnotations.push(annotations); + } + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.check("manual"); + + const state = yield* updates.getState; + const loggedAnnotation = loggedAnnotations.at(-1); + assert.isDefined(loggedAnnotation); + assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError"); + assert.isNull(loggedAnnotation.channel); + assert.notProperty(loggedAnnotation, "error"); + assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret"); + assert.equal( + state.message, + "Electron updater failed to check for updates on channel default.", + ); + assert.notInclude(state.message ?? "", "secret"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + TestClock.layer(), + harness.layer, + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + + it.effect("recovers download state after an unexpected setup failure", () => { + let disableDifferentialCalls = 0; + const harness = makeHarness({ + setDisableDifferentialDownload: Effect.suspend(() => { + disableDifferentialCalls += 1; + return disableDifferentialCalls === 1 + ? Effect.void + : Effect.die(new Error("download setup failed")); + }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.download; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "available"); + assert.equal(failedState.errorContext, "download"); + assert.equal(failedState.message, "Desktop update download action failed unexpectedly."); + + const changedState = yield* updates.setChannel("nightly"); + assert.equal(changedState.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("restores download state and permits retry after interruption", () => + Effect.gen(function* () { + const actionStarted = yield* Deferred.make(); + let disableDifferentialCalls = 0; + const harness = makeHarness({ + setDisableDifferentialDownload: Effect.suspend(() => { + disableDifferentialCalls += 1; + if (disableDifferentialCalls === 1) { + return Effect.void; + } + if (disableDifferentialCalls === 2) { + return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never)); + } + return Effect.void; + }), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const downloadFiber = yield* updates.download.pipe(Effect.forkScoped); + yield* Deferred.await(actionStarted); + yield* Fiber.interrupt(downloadFiber); + + const interruptedState = yield* updates.getState; + assert.equal(interruptedState.status, "available"); + assert.isNull(interruptedState.message); + + const retry = yield* updates.download; + assert.isTrue(retry.accepted); + assert.isTrue(retry.completed); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + + it.effect("clears quitting state after an unexpected install setup failure", () => { + const harness = makeHarness({ + stopBackend: Effect.die(new Error("backend stop failed")), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "downloaded"); + assert.equal(failedState.errorContext, "install"); + assert.equal(failedState.message, "Desktop update install action failed unexpectedly."); + + const changedState = yield* updates.setChannel("nightly"); + assert.equal(changedState.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); @@ -284,6 +512,7 @@ describe("DesktopUpdates", () => { const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); assert.equal(error.action, "check"); + assert.equal(error.requestedChannel, "nightly"); } yield* Deferred.succeed(releaseCheck, undefined); @@ -292,4 +521,31 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), ); + + it.effect("preserves settings failure context when an update channel cannot be persisted", () => { + const diskFailure = new Error("disk exploded"); + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/settings.json", + cause: diskFailure, + }); + const harness = makeHarness({ setUpdateChannelError: settingsFailure }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const error = yield* updates.setChannel("nightly").pipe(Effect.flip); + + assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); + assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); + assert.equal(error.channel, "nightly"); + assert.strictEqual(error.cause, settingsFailure); + assert.strictEqual(error.cause.cause, diskFailure); + assert.equal(error.message, "Failed to persist the nightly desktop update channel."); + assert.notInclude(error.message, diskFailure.message); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index e6c81d8d25b..aecbdcfc3e8 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,13 +1,13 @@ -import type { - DesktopRuntimeInfo, - DesktopUpdateActionResult, - DesktopUpdateChannel, - DesktopUpdateCheckResult, - DesktopUpdateState, +import { + DesktopUpdateChannelSchema, + type DesktopRuntimeInfo, + type DesktopUpdateActionResult, + type DesktopUpdateChannel, + type DesktopUpdateCheckResult, + type DesktopUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -60,48 +60,102 @@ const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressIn const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); -export class DesktopUpdateActionInProgressError extends Data.TaggedError( +export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass()( "DesktopUpdateActionInProgressError", -)<{ - readonly action: "check" | "download" | "install"; -}> { - override get message() { - return `Cannot change update tracks while an update ${this.action} action is in progress.`; + { + action: Schema.Literals(["check", "download", "install"]), + requestedChannel: DesktopUpdateChannelSchema, + }, +) { + override get message(): string { + return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`; } } -export class DesktopUpdatePersistenceError extends Data.TaggedError( - "DesktopUpdatePersistenceError", -)<{ - readonly cause: DesktopAppSettings.DesktopSettingsWriteError; -}> { - override get message() { - return "Failed to persist desktop update settings."; +export class DesktopUpdateChannelPersistenceError extends Schema.TaggedErrorClass()( + "DesktopUpdateChannelPersistenceError", + { + channel: DesktopUpdateChannelSchema, + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist the ${this.channel} desktop update channel.`; } } -export type DesktopUpdateConfigureError = never; +export class DesktopUpdatePollerError extends Schema.TaggedErrorClass()( + "DesktopUpdatePollerError", + { + poller: Schema.Literals(["startup", "poll"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop update ${this.poller} poller failed.`; + } +} -export type DesktopUpdateSetChannelError = - | DesktopUpdateActionInProgressError - | DesktopUpdatePersistenceError; +export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass()( + "DesktopUpdateEventHandlingError", + { + event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to handle desktop update ${this.event} event.`; + } +} -export interface DesktopUpdatesShape { - readonly getState: Effect.Effect; - readonly emitState: Effect.Effect; - readonly disabledReason: Effect.Effect>; - readonly configure: Effect.Effect; - readonly setChannel: ( - channel: DesktopUpdateChannel, - ) => Effect.Effect; - readonly check: (reason: string) => Effect.Effect; - readonly download: Effect.Effect; - readonly install: Effect.Effect; +export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass()( + "DesktopUpdaterReportedError", + { + operation: Schema.Literals(["check", "download", "install", "background"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop updater ${this.operation} operation reported an error.`; + } +} + +export class DesktopUpdateUnexpectedActionError extends Schema.TaggedErrorClass()( + "DesktopUpdateUnexpectedActionError", + { + action: Schema.Literals(["download", "install"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop update ${this.action} action failed unexpectedly.`; + } } -export class DesktopUpdates extends Context.Service()( - "@t3tools/desktop/updates/DesktopUpdates", -) {} +export type DesktopUpdateConfigureError = never; + +export const DesktopUpdateSetChannelError = Schema.Union([ + DesktopUpdateActionInProgressError, + DesktopUpdateChannelPersistenceError, +]); +export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; +export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); + +export class DesktopUpdates extends Context.Service< + DesktopUpdates, + { + readonly getState: Effect.Effect; + readonly emitState: Effect.Effect; + readonly disabledReason: Effect.Effect>; + readonly configure: Effect.Effect; + readonly setChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; + readonly check: (reason: string) => Effect.Effect; + readonly download: Effect.Effect; + readonly install: Effect.Effect; + } +>()("@t3tools/desktop/updates/DesktopUpdates") {} const { logInfo: logUpdaterInfo, @@ -127,7 +181,7 @@ function parseAppUpdateYml(raw: string): Effect.Effect reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), ); - yield* logUpdaterError("failed to check for updates", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + }); return true; }), - ), + }), Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), ); }); @@ -341,19 +400,50 @@ const make = Effect.gen(function* () { yield* electronUpdater.downloadUpdate; return { accepted: true, completed: true }; }).pipe( - Effect.catch( - Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { + Effect.catchTags({ + ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( + function* (error) { + yield* updateState((current) => + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + }); + return { accepted: true, completed: false }; + }, + ), + }), + Effect.onInterrupt(() => + updateState((current) => (current.status === "downloading" ? state : current)).pipe( + Effect.asVoid, + ), + ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause }); + return Effect.gen(function* () { yield* updateState((current) => reduceDesktopUpdateStateOnDownloadFailure(current, error.message), ); - yield* logUpdaterError("failed to download update", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); return { accepted: true, completed: false }; - }), - ), + }); + }), Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), ); }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + const resetInstallAction = Effect.all( + [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)], + { discard: true }, + ); + const installDownloadedUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); if ( @@ -376,14 +466,38 @@ const make = Effect.gen(function* () { }); return { accepted: true, completed: false }; }).pipe( - Effect.catch( - Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { - yield* Ref.set(updateInstallInFlightRef, false); + Effect.catchTags({ + ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( + function* (error) { + yield* resetInstallAction; + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + isSilent: error.isSilent, + isForceRunAfter: error.isForceRunAfter, + }); + return { accepted: true, completed: false }; + }, + ), + }), + Effect.onInterrupt(() => resetInstallAction), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + yield* resetInstallAction; + const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, error.message), ); - yield* Ref.set(desktopState.quitting, false); - yield* logUpdaterError("failed to install update", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); return { accepted: true, completed: false }; }), ), @@ -393,17 +507,31 @@ const make = Effect.gen(function* () { const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( Effect.andThen(checkForUpdates("startup")), - Effect.catchCause((cause) => - logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdatePollerError({ poller: "startup", cause }); + return logUpdaterError(error.message, { + errorTag: error._tag, + poller: error.poller, + }); + }), Effect.forkScoped, ); yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( Effect.andThen(checkForUpdates("poll")), Effect.forever, - Effect.catchCause((cause) => - logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdatePollerError({ poller: "poll", cause }); + return logUpdaterError(error.message, { + errorTag: error._tag, + poller: error.poller, + }); + }), Effect.forkScoped, ); }).pipe(Effect.withSpan("desktop.updates.startPollers")); @@ -434,11 +562,16 @@ const make = Effect.gen(function* () { yield* logUpdaterInfo("update available", { version: info.version }); }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed update-available event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -451,14 +584,23 @@ const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( - error: unknown, + cause: unknown, ) { - const message = error instanceof Error ? error.message : String(error); + const activeAction = yield* activeUpdateAction; + const error = new DesktopUpdaterReportedError({ + operation: Option.getOrElse(activeAction, () => "background" as const), + cause, + }); if (yield* Ref.get(updateInstallInFlightRef)) { yield* Ref.set(updateInstallInFlightRef, false); yield* Ref.set(desktopState.quitting, false); - yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); - yield* logUpdaterError("updater error", { message }); + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); return; } @@ -468,7 +610,7 @@ const make = Effect.gen(function* () { yield* updateState((current) => ({ ...current, status: "error", - message, + message: error.message, checkedAt, downloadPercent: null, errorContext, @@ -476,7 +618,10 @@ const make = Effect.gen(function* () { })); } - yield* logUpdaterError("updater error", { message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); }); const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( @@ -498,11 +643,16 @@ const make = Effect.gen(function* () { } }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed download-progress event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -517,11 +667,16 @@ const make = Effect.gen(function* () { yield* logUpdaterInfo("update downloaded", { version: info.version }); }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed update-downloaded event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -597,7 +752,10 @@ const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel: nextChannel }); const activeAction = yield* activeUpdateAction; if (Option.isSome(activeAction)) { - return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); + return yield* new DesktopUpdateActionInProgressError({ + action: activeAction.value, + requestedChannel: nextChannel, + }); } const state = yield* Ref.get(updateStateRef); @@ -607,7 +765,11 @@ const make = Effect.gen(function* () { yield* desktopSettings .setUpdateChannel(nextChannel) - .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }), + ), + ); const enabled = yield* shouldEnableAutoUpdates; yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 62d619fe18b..04a1971ce46 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -46,14 +46,14 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, on: () => Effect.void, -} satisfies ElectronApp.ElectronAppShape); +} satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, -} satisfies ElectronDialog.ElectronDialogShape); +} satisfies ElectronDialog.ElectronDialog["Service"]); const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), @@ -64,7 +64,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { check: () => Effect.die("unexpected check"), download: Effect.die("unexpected download"), install: Effect.die("unexpected install"), -} satisfies DesktopUpdates.DesktopUpdatesShape); +} satisfies DesktopUpdates.DesktopUpdates["Service"]); const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => Layer.succeed(DesktopWindow.DesktopWindow, { @@ -76,7 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendReady: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, - } satisfies DesktopWindow.DesktopWindowShape); + } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( applicationMenuTemplate: Deferred.Deferred, @@ -86,7 +86,7 @@ const makeElectronMenuLayer = ( Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), popupTemplate: () => Effect.void, showContextMenu: () => Effect.succeed(Option.none()), - } satisfies ElectronMenu.ElectronMenuShape); + } satisfies ElectronMenu.ElectronMenu["Service"]); describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 2d41fa9db86..a52707627b0 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -1,12 +1,12 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import type * as Electron from "electron"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; @@ -14,13 +14,23 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; -export interface DesktopApplicationMenuShape { - readonly configure: Effect.Effect; +export class DesktopApplicationMenuActionError extends Schema.TaggedErrorClass()( + "DesktopApplicationMenuActionError", + { + action: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop menu action "${this.action}" failed.`; + } } export class DesktopApplicationMenu extends Context.Service< DesktopApplicationMenu, - DesktopApplicationMenuShape + { + readonly configure: Effect.Effect; + } >()("@t3tools/desktop/window/DesktopApplicationMenu") {} type DesktopApplicationMenuRuntimeServices = @@ -28,9 +38,9 @@ type DesktopApplicationMenuRuntimeServices = | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; -const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater"); +const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater"); -const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu"); +const { logError: logMenuError } = makeComponentLogger("desktop-menu"); const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( action: string, @@ -39,11 +49,7 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function yield* desktopWindow.dispatchMenuAction(action); }); -const checkForUpdatesFromMenu: Effect.Effect< - void, - never, - DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog -> = Effect.gen(function* () { +const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; const result = yield* updates.check("menu"); @@ -67,11 +73,7 @@ const checkForUpdatesFromMenu: Effect.Effect< } }).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); -const handleCheckForUpdatesMenuClick: Effect.Effect< - void, - DesktopWindow.DesktopWindowError, - DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow -> = Effect.gen(function* () { +const handleCheckForUpdatesMenuClick = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; const disabledReason = yield* updates.disabledReason; @@ -94,7 +96,7 @@ const handleCheckForUpdatesMenuClick: Effect.Effect< yield* checkForUpdatesFromMenu; }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -110,12 +112,10 @@ const make = Effect.gen(function* () { effect.pipe( Effect.annotateLogs({ action }), Effect.withSpan("desktop.menu.action"), - Effect.catchCause((cause) => - logMenuError("desktop menu action failed", { - action, - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + const error = new DesktopApplicationMenuActionError({ action, cause }); + return logMenuError(error.message, { error }); + }), ), ); }; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index e22db07c0cd..76413dd0b55 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -91,7 +91,7 @@ const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { png: Option.none(), }), resolveResourcePath: () => Effect.succeed(Option.none()), -} satisfies DesktopAssets.DesktopAssetsShape); +} satisfies DesktopAssets.DesktopAssets["Service"]); const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), @@ -106,19 +106,19 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), -} satisfies DesktopServerExposure.DesktopServerExposureShape); +} satisfies DesktopServerExposure.DesktopServerExposure["Service"]); const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, popupTemplate: () => Effect.void, showContextMenu: () => Effect.succeed(Option.none()), -} satisfies ElectronMenu.ElectronMenuShape); +} satisfies ElectronMenu.ElectronMenu["Service"]); const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { shouldUseDarkColors: Effect.succeed(false), setSource: () => Effect.void, onUpdated: () => Effect.void, -} satisfies ElectronTheme.ElectronThemeShape); +} satisfies ElectronTheme.ElectronTheme["Service"]); const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( Layer.provide( @@ -156,7 +156,7 @@ function makeTestLayer(input: { sendAll: () => Effect.void, destroyAll: Effect.void, syncAllAppearance: (sync) => sync(input.window), - } satisfies ElectronWindow.ElectronWindowShape); + } satisfies ElectronWindow.ElectronWindow["Service"]); return DesktopWindow.layer.pipe( Layer.provide( @@ -173,7 +173,7 @@ function makeTestLayer(input: { return true; }), copyText: () => Effect.void, - } satisfies ElectronShell.ElectronShellShape), + } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, electronWindowLayer, Layer.mock(PreviewManager.PreviewManager)({ @@ -191,19 +191,19 @@ describe("DesktopWindow", () => { it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "http://127.0.0.1:3773/", - navigationUrl: "http://127.0.0.1:3773/settings/connections", + applicationUrl: "t3code://app/", + navigationUrl: "t3code://app/settings/connections", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "http://127.0.0.1:3773/", + applicationUrl: "t3code://app/", navigationUrl: "https://accounts.microsoft.com/oauth", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "http://127.0.0.1:3773/", + applicationUrl: "t3code://app/", navigationUrl: "not a url", }), ); @@ -231,7 +231,7 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(createCount), 1); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 642abd535ae..e6cfce3c54f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,5 +1,4 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -9,15 +8,15 @@ import type * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; -import * as PreviewManager from "../preview/Manager.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -32,7 +31,6 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets - | DesktopServerExposure.DesktopServerExposure | DesktopState.DesktopState | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell @@ -40,45 +38,26 @@ type DesktopWindowRuntimeServices = | ElectronWindow.ElectronWindow | PreviewManager.PreviewManager; -export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( - "DesktopWindowDevServerUrlMissingError", -)<{}> { - override get message() { - return "VITE_DEV_SERVER_URL is required in desktop development."; - } -} - export type DesktopWindowError = - | DesktopWindowDevServerUrlMissingError | ElectronWindow.ElectronWindowCreateError | PreviewManager.PreviewManagerError; -export interface DesktopWindowShape { - readonly createMain: Effect.Effect; - readonly ensureMain: Effect.Effect; - readonly revealOrCreateMain: Effect.Effect; - readonly activate: Effect.Effect; - readonly createMainIfBackendReady: Effect.Effect; - readonly handleBackendReady: Effect.Effect; - readonly dispatchMenuAction: (action: string) => Effect.Effect; - readonly syncAppearance: Effect.Effect; -} - -export class DesktopWindow extends Context.Service()( - "@t3tools/desktop/window/DesktopWindow", -) {} +export class DesktopWindow extends Context.Service< + DesktopWindow, + { + readonly createMain: Effect.Effect; + readonly ensureMain: Effect.Effect; + readonly revealOrCreateMain: Effect.Effect; + readonly activate: Effect.Effect; + readonly createMainIfBackendReady: Effect.Effect; + readonly handleBackendReady: Effect.Effect; + readonly dispatchMenuAction: (action: string) => Effect.Effect; + readonly syncAppearance: Effect.Effect; + } +>()("@t3tools/desktop/window/DesktopWindow") {} const { logInfo: logWindowInfo, logWarning: logWindowWarning } = - DesktopObservability.makeComponentLogger("desktop-window"); - -function resolveDesktopDevServerUrl( - environment: DesktopEnvironment.DesktopEnvironmentShape, -): Effect.Effect { - return Option.match(environment.devServerUrl, { - onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), - onSome: (url) => Effect.succeed(url.href), - }); -} + makeComponentLogger("desktop-window"); function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, @@ -163,7 +142,7 @@ function bindFirstRevealTrigger( } } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const assets = yield* DesktopAssets.DesktopAssets; const electronMenu = yield* ElectronMenu.ElectronMenu; @@ -171,18 +150,16 @@ const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - const createWindow = Effect.fn("desktop.window.createWindow")(function* ( - backendHttpUrl: URL, - ): Effect.fn.Return { + const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< + Electron.BrowserWindow, + DesktopWindowError + > { yield* previewManager.getBrowserSession(); - const applicationUrl = environment.isDevelopment - ? yield* resolveDesktopDevServerUrl(environment) - : backendHttpUrl.href; + const applicationUrl = getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; @@ -350,8 +327,7 @@ const make = Effect.gen(function* () { }); const createMain = Effect.gen(function* () { - const backendConfig = yield* serverExposure.backendConfig; - const window = yield* createWindow(backendConfig.httpBaseUrl); + const window = yield* createWindow(); yield* electronWindow.setMain(window); yield* logWindowInfo("main window created"); return window; @@ -404,7 +380,7 @@ const make = Effect.gen(function* () { const send = () => { if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); void runPromise(electronWindow.reveal(targetWindow)); }; diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index dceefc14e9e..96e089b9183 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -56,6 +56,12 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), define: publicConfigDefine, entry: ["src/preload.ts"], + deps: { + // Sandboxed Electron preloads cannot reliably resolve package imports + // from inside the packaged ASAR. Bundle Clerk's preload bridge into the + // preload artifact instead of leaving a runtime require() behind. + alwaysBundle: (id) => id === "@clerk/electron" || id.startsWith("@clerk/electron/"), + }, }, { format: "cjs", diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index e60637cbfd1..5d9fc4e8f3b 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,4 +1,6 @@ --- +import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; + interface Props { title?: string; description?: string; @@ -36,17 +38,17 @@ const { @@ -62,7 +64,7 @@ const { © {new Date().getFullYear()} T3 Tools Inc · MIT licensed @@ -329,23 +331,36 @@ const { gap: 8px; } - .nav-gh { + .nav-stars { display: inline-flex; align-items: center; - gap: 6px; - padding: 7px 12px; + gap: 7px; + height: 36px; + padding: 0 14px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.02); color: var(--fg-muted); - font-family: var(--font-mono); - font-size: 12px; - transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease; + font-size: 13px; + letter-spacing: -0.01em; + white-space: nowrap; + transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease; } - .nav-gh:hover { + .nav-stars:hover { color: var(--fg); - background: rgba(255, 255, 255, 0.04); border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.04); + } + + .nav-stars strong { + color: var(--fg); + font-weight: 600; + } + + .nav-stars svg { + color: var(--warn); + flex-shrink: 0; } .main { @@ -407,4 +422,17 @@ const { padding-right: 20px; } } + + @media (max-width: 420px) { + .nav-inner { + gap: 12px; + } + + .nav-stars { + height: 34px; + gap: 6px; + padding: 0 12px; + font-size: 12px; + } + } diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts new file mode 100644 index 00000000000..5ff5958c588 --- /dev/null +++ b/apps/marketing/src/lib/site.ts @@ -0,0 +1,6 @@ +export const GITHUB_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; + +export const MARKETING_STATS = { + githubStars: "12k+", + users: "100,000", +} as const; diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 0b76f350896..69de2088d43 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -1,5 +1,6 @@ --- import Layout from "../layouts/Layout.astro"; +import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; import { tweets } from "../lib/tweets"; const desktopEndorsementRows = [ @@ -55,11 +56,12 @@ const mobileEndorsementRows = [ Download for macOS - - Steal our code (legally) + @@ -82,8 +84,8 @@ const mobileEndorsementRows = [
-

Developers love T3 Code

-

Real reactions from people building with T3 Code today.

+

Tolerated by over {MARKETING_STATS.users} devs

+

Some of them even tweeted about it.

@@ -282,10 +284,6 @@ const mobileEndorsementRows = [
Open source

If you don't like something, fork it.

-

- T3 Code is as open as they come. We built this app to be modifiable, - customizable, and forkable. Go nuts - that's the whole point. -

@@ -305,43 +303,44 @@ const mobileEndorsementRows = [
-
-
-
MIT
-
License · commercial-friendly
-
-
-
TypeScript
-
End-to-end, strictly typed
+
+
+
    +
  • + + Change the UI. Restyle every surface to match your taste. +
  • +
  • + + Add an agent. Wire in your own tools, models, and flows. +
  • +
  • + + Ship your own build. Self-host it or distribute it as your own. +
  • +
-
-
1 monorepo
-
Desktop · web · server · harnesses
-
-
-
No telemetry
-
Unless you opt in. Full stop.
+ +
- -
@@ -515,7 +514,7 @@ const mobileEndorsementRows = [ .hero-title { font-size: clamp(38px, 5.6vw, 76px); - margin: 28px auto 22px; + margin: 48px auto 22px; max-width: 20ch; text-wrap: balance; } @@ -531,12 +530,42 @@ const mobileEndorsementRows = [ .hero-actions { display: flex; - gap: 10px; - justify-content: center; - flex-wrap: wrap; + flex-direction: column; + align-items: center; + gap: 16px; margin-bottom: 56px; } + .hero-source-link { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fg-muted); + font-size: 14px; + font-weight: 500; + letter-spacing: -0.01em; + transition: color 0.18s ease; + } + + .hero-source-link:hover { + color: var(--fg); + } + + .hero-source-mark { + flex-shrink: 0; + } + + .hero-source-arrow { + flex-shrink: 0; + opacity: 0.6; + transition: transform 0.18s ease, opacity 0.18s ease; + } + + .hero-source-link:hover .hero-source-arrow { + transform: translate(2px, -2px); + opacity: 1; + } + /* Download button icons (platform-aware) */ .dl-icon { display: none; @@ -656,6 +685,30 @@ const mobileEndorsementRows = [ } } + @media (max-width: 340px) { + .hero-float-mark.hf-opencode, + .hero-float-mark.hf-cursor { + top: 580px; + width: 52px; + height: 52px; + border-radius: 14px; + } + + .hero-float-mark.hf-opencode { + left: 0; + } + + .hero-float-mark.hf-cursor { + right: 0; + } + + .hero-float-mark.hf-opencode img, + .hero-float-mark.hf-cursor img { + width: 30px; + height: 30px; + } + } + .hero-preview { max-width: 1180px; margin: 0 auto; @@ -759,6 +812,11 @@ const mobileEndorsementRows = [ margin-bottom: 18px; } + .endorsements-count { + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .endorsements-head p { color: var(--fg-muted); font-size: 18px; @@ -962,12 +1020,11 @@ const mobileEndorsementRows = [ text-align: center; margin: 0 auto 56px; } - .open-head p { margin: 0 auto; } .open-grid { display: grid; grid-template-columns: 1.25fr 1fr; - gap: 20px; margin-bottom: 32px; + gap: 20px; } .open-term { padding: 0; overflow: hidden; } @@ -1009,27 +1066,85 @@ const mobileEndorsementRows = [ animation: blink 1s steps(2) infinite; } - .open-stats { - display: grid; - grid-template-columns: 1fr 1fr; + .open-pitch { + padding: 32px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 32px; + background: + radial-gradient(110% 75% at 100% 0%, var(--accent-dim), transparent 60%), + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent); + } + + .open-pitch-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 16px; + } + + .open-pitch-list li { + display: flex; + align-items: flex-start; gap: 12px; + font-size: 15px; + line-height: 1.5; + color: var(--fg-muted); + } + + .open-pitch-list strong { + color: var(--fg); + font-weight: 600; + } + + .open-pitch-mark { + flex: none; + display: grid; + place-items: center; + width: 22px; + height: 22px; + margin-top: 1px; + border-radius: 7px; + color: var(--accent); + background: var(--accent-dim); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); } - .open-stat { - padding: 22px; - display: flex; flex-direction: column; gap: 6px; + + .open-pitch-footer { + display: flex; + flex-direction: column; + gap: 18px; } - .open-stat-val { - font-size: 22px; font-weight: 500; - letter-spacing: -0.015em; + + .open-pitch-meta { + display: flex; + align-items: center; + gap: 8px; + color: var(--fg-dim); + font-family: var(--font-mono); + font-size: 11px; } - .open-stat-lbl { - font-family: var(--font-mono); font-size: 10.5px; - color: var(--fg-dim); letter-spacing: 0.04em; + + .open-pitch-actions { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; } - .open-ctas { - display: flex; gap: 10px; - justify-content: center; flex-wrap: wrap; + .open-source-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--fg-muted); + font-size: 13px; + font-weight: 500; + transition: color 0.18s ease; + } + + .open-source-link:hover { + color: var(--fg); } /* ── Final CTA ────────────────────────────────────────── */ diff --git a/apps/mobile/.swiftlint.yml b/apps/mobile/.swiftlint.yml index 83fc429b731..0714ce90e63 100644 --- a/apps/mobile/.swiftlint.yml +++ b/apps/mobile/.swiftlint.yml @@ -1,5 +1,6 @@ included: - ios/T3Code + - modules/t3-composer-editor/ios - modules/t3-terminal/ios - modules/t3-review-diff/ios diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 7cbb8335deb..8cdf6f2e25c 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -131,6 +131,11 @@ const config: ExpoConfig = { { ios: { deploymentTarget: "18.0", + // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies. + extraPods: [ + { name: "GoogleUtilities", modular_headers: true }, + { name: "RecaptchaInterop", modular_headers: true }, + ], }, }, ], diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 4e6b55a4223..14c5ea58669 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -1,7 +1,8 @@ { "cli": { "version": ">= 18.4.0", - "appVersionSource": "remote" + "appVersionSource": "remote", + "promptToConfigurePushNotifications": false }, "build": { "development": { diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 0fbf4fb3c9d..b2014bf9353 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -192,11 +192,31 @@ } } -/* ─── Font family ───────────────────────────────────────────────────── */ +/* ─── Typography ────────────────────────────────────────────────────── */ @theme { --font-sans: "DMSans_400Regular"; --font-medium: "DMSans_500Medium"; --font-bold: "DMSans_700Bold"; + + /* Keep this scale aligned with src/lib/typography.ts for native style props. */ + --text-3xs: 10px; + --text-3xs--line-height: 13px; + --text-2xs: 11px; + --text-2xs--line-height: 15px; + --text-xs: 12px; + --text-xs--line-height: 16px; + --text-sm: 13px; + --text-sm--line-height: 18px; + --text-base: 15px; + --text-base--line-height: 22px; + --text-lg: 17px; + --text-lg--line-height: 22px; + --text-xl: 20px; + --text-xl--line-height: 26px; + --text-2xl: 24px; + --text-2xl--line-height: 30px; + --text-3xl: 28px; + --text-3xl--line-height: 34px; } /* ─── Custom utilities ──────────────────────────────────────────────── */ diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index a88acbc31f7..6f4dc575b12 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -275,8 +275,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { fileTint: "#737373" ) private var fontFamily = "DMSans_400Regular" - private var fontSize: CGFloat = 15 - private var lineHeight: CGFloat = 22 + private var fontSize: CGFloat = 14 + private var lineHeight: CGFloat = 20 private var contentInsetVertical: CGFloat = 0 private var shouldAutoFocus = false private var didAutoFocus = false @@ -460,9 +460,19 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { guard !isApplyingControlledValue else { return } + restoreBaseTypingAttributes() emitSelection() } + public func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + restoreBaseTypingAttributes() + return true + } + public func textViewDidBeginEditing(_ textView: UITextView) { onComposerFocus() } @@ -484,6 +494,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { let targetSelection = requestedSelection ?? previousSelection requestedSelection = nil textView.selectedRange = displayRange(for: targetSelection) + restoreBaseTypingAttributes() isApplyingControlledValue = false updatePlaceholderVisibility() emitContentSizeIfNeeded() @@ -556,7 +567,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { size: image.size, baselineOffset: baselineOffset ) - return NSAttributedString(attachment: attachment) + let attributedAttachment = NSMutableAttributedString(attachment: attachment) + attributedAttachment.addAttributes( + baseAttributes(), + range: NSRange(location: 0, length: attributedAttachment.length) + ) + return attributedAttachment } private func renderChip( @@ -660,11 +676,18 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { let font = UIFont(name: fontFamily, size: fontSize) ?? UIFont.systemFont(ofSize: fontSize) textView.font = font - textView.typingAttributes = baseAttributes() + restoreBaseTypingAttributes() placeholderLabel.font = font setNeedsLayout() } + private func restoreBaseTypingAttributes() { + guard textView.markedTextRange == nil else { + return + } + textView.typingAttributes = baseAttributes() + } + private func applyTheme() { textView.textColor = UIColor(composerHex: theme.text) ?? .label placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 3ebfdb7a11e..6fa61aab17e 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -70,7 +70,12 @@ static void T3MarkdownTextApplyAttachments( renderingMode:UIImageRenderingModeAlwaysOriginal]; } attachment.image = image ?: [[UIImage alloc] init]; - attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); + attachment.bounds = CGRectMake( + 0, + T3MarkdownTextAttachmentBaselineOffset(attachmentRange), + attachmentSize, + attachmentSize); const NSRange range = NSMakeRange( attachmentRange.location, MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); @@ -80,104 +85,6 @@ static void T3MarkdownTextApplyAttachments( } } -static NSArray *> *T3MarkdownTextExtractChipBackgrounds( - NSMutableAttributedString *attributedString, - const std::vector &chipRanges) -{ - NSMutableArray *> *backgrounds = [NSMutableArray array]; - for (const auto &chipRange : chipRanges) { - if (chipRange.length == 0 || chipRange.location >= attributedString.length) { - continue; - } - - const NSRange range = NSMakeRange( - chipRange.location, - MIN(chipRange.length, attributedString.length - chipRange.location)); - UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName - atIndex:range.location - effectiveRange:nil]; - UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName - atIndex:range.location - effectiveRange:nil]; - if (color == nil) { - continue; - } - [backgrounds addObject:@{ - @"range": [NSValue valueWithRange:range], - @"color": color, - @"strokeColor": [foregroundColor - colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor, - }]; - [attributedString removeAttribute:NSBackgroundColorAttributeName range:range]; - } - return backgrounds; -} - -@interface T3MarkdownTextBackingView : UITextView -@property(nonatomic, copy) NSArray *> *chipBackgrounds; -@end - -@implementation T3MarkdownTextBackingView - -- (void)drawRect:(CGRect)rect -{ - [self.layoutManager ensureLayoutForTextContainer:self.textContainer]; - CGContextRef context = UIGraphicsGetCurrentContext(); - if (context != nil) { - CGContextSaveGState(context); - CGContextResetClip(context); - CGContextClipToRect(context, self.bounds); - } - for (NSDictionary *background in self.chipBackgrounds) { - const NSRange characterRange = [background[@"range"] rangeValue]; - UIColor *color = background[@"color"]; - UIColor *strokeColor = background[@"strokeColor"]; - if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) { - continue; - } - - const NSRange glyphRange = - [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil]; - [color setFill]; - [self.layoutManager - enumerateEnclosingRectsForGlyphRange:glyphRange - withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) - inTextContainer:self.textContainer - usingBlock:^(CGRect glyphRect, BOOL *stop) { - const CGFloat chipHeight = 22; - CGRect chipRect = CGRectMake( - glyphRect.origin.x - 4, - CGRectGetMidY(glyphRect) - chipHeight / 2, - glyphRect.size.width + 8, - chipHeight); - chipRect.origin.x += self.textContainerInset.left; - chipRect.origin.y += self.textContainerInset.top; - const CGFloat minimumX = self.textContainerInset.left + 0.5; - const CGFloat maximumX = - CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5; - if (chipRect.origin.x < minimumX) { - chipRect.size.width -= minimumX - chipRect.origin.x; - chipRect.origin.x = minimumX; - } - if (CGRectGetMaxX(chipRect) > maximumX) { - chipRect.size.width = MAX(0, maximumX - chipRect.origin.x); - } - UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6]; - [path fill]; - [strokeColor setStroke]; - path.lineWidth = 1; - [path stroke]; - }]; - } - if (context != nil) { - CGContextRestoreGState(context); - } - - [super drawRect:rect]; -} - -@end - @protocol T3MarkdownOutsideTapTarget - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; @end @@ -285,7 +192,7 @@ @interface T3MarkdownText () @implementation T3MarkdownText { UIView * _view; - T3MarkdownTextBackingView * _textView; + UITextView * _textView; T3MarkdownTextShadowNode::ConcreteState::Shared _state; __weak UIWindow * _outsideTapWindow; BOOL _suppressSelectionChange; @@ -308,7 +215,7 @@ - (instancetype)initWithFrame:(CGRect)frame self.contentView = _view; self.clipsToBounds = true; - _textView = [[T3MarkdownTextBackingView alloc] init]; + _textView = [[UITextView alloc] init]; _attachmentImages = [[NSMutableDictionary alloc] init]; _pendingAttachmentUris = [[NSMutableSet alloc] init]; _textView.scrollEnabled = false; @@ -405,9 +312,6 @@ - (void)drawRect:(CGRect)rect convertedAttrString, _state->getData().attachmentRanges, _attachmentImages); - _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds( - convertedAttrString, - _state->getData().chipRanges); [self loadAttachmentImages:_state->getData().attachmentRanges]; // Setting attributedText clears any active text selection, and re-assigning diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index afc276aedda..99417490a63 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -28,18 +28,20 @@ struct T3MarkdownTextAttachmentRange { std::string imageUri; }; -struct T3MarkdownTextChipRange { - size_t location; - size_t length; - bool isSkill; -}; +inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { + return 14; +} + +inline Float T3MarkdownTextAttachmentBaselineOffset( + const T3MarkdownTextAttachmentRange &) { + return -2; +} class T3MarkdownTextStateReal final { public: AttributedString attributedString; std::vector paragraphStyleRanges; std::vector attachmentRanges; - std::vector chipRanges; }; class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< @@ -72,6 +74,5 @@ T3MarkdownTextStateReal> { mutable AttributedString _attributedString; mutable std::vector _paragraphStyleRanges; mutable std::vector _attachmentRanges; - mutable std::vector _chipRanges; }; } // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index 00fda742284..b9abe452fb9 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -9,9 +9,8 @@ namespace facebook::react { static constexpr Float ParagraphStyleEncodingOffset = 1000; -static constexpr auto ChipNativeIdPrefix = "t3-chip-"; -static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:"; -static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:"; +static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; +static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; static void applyParagraphStyles( NSMutableAttributedString *attributedString, @@ -58,7 +57,12 @@ static void applyAttachments( NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; attachment.image = [[UIImage alloc] init]; - attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); + attachment.bounds = CGRectMake( + 0, + T3MarkdownTextAttachmentBaselineOffset(attachmentRange), + attachmentSize, + attachmentSize); const NSRange range = NSMakeRange( attachmentRange.location, MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); @@ -91,7 +95,6 @@ static void applyAttachments( auto baseAttributedString = AttributedString{}; auto paragraphStyleRanges = std::vector{}; auto attachmentRanges = std::vector{}; - auto chipRanges = std::vector{}; size_t utf16Offset = 0; const auto &children = getChildren(); for (size_t i = 0; i < children.size(); i++) { @@ -184,25 +187,19 @@ static void applyAttachments( props.shadowRadius - ParagraphStyleEncodingOffset, }); } - if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) { - chipRanges.push_back(T3MarkdownTextChipRange{ - utf16Offset, - fragmentLength, - props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0, - }); - } - if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset + 1, + utf16Offset, 1, - props.nativeId.substr(std::char_traits::length(FileChipNativeIdPrefix)), + props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), }); } else if ( - props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset + 1, + utf16Offset, 1, - props.nativeId.substr(std::char_traits::length(SkillChipNativeIdPrefix)), + props.nativeId.substr( + std::char_traits::length(SkillAttachmentNativeIdPrefix)), }); } utf16Offset += fragmentLength; @@ -213,7 +210,6 @@ static void applyAttachments( _attributedString = baseAttributedString; _paragraphStyleRanges = paragraphStyleRanges; _attachmentRanges = attachmentRanges; - _chipRanges = chipRanges; NSMutableAttributedString *convertedAttributedString = [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; @@ -263,7 +259,6 @@ static void applyAttachments( _attributedString, _paragraphStyleRanges, _attachmentRanges, - _chipRanges, }); } } diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs index 87f17c28e0f..2c2cc43bc65 100644 --- a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs +++ b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs @@ -1,16 +1,19 @@ -import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { getBuiltInSpriteSheet } from "@pierre/trees"; -const scriptDirectory = dirname(fileURLToPath(import.meta.url)); -const moduleDirectory = resolve(scriptDirectory, ".."); -const repositoryRoot = resolve(moduleDirectory, "../../../.."); -const outputDirectory = join(moduleDirectory, "assets/file-icons"); -const generatedModulePath = join(moduleDirectory, "src/markdownFileIcons.generated.ts"); -const webIconSource = readFileSync(join(repositoryRoot, "apps/web/src/pierre-icons.ts"), "utf8"); +const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const moduleDirectory = NodePath.resolve(scriptDirectory, ".."); +const repositoryRoot = NodePath.resolve(moduleDirectory, "../../../.."); +const outputDirectory = NodePath.join(moduleDirectory, "assets/file-icons"); +const generatedModulePath = NodePath.join(moduleDirectory, "src/markdownFileIcons.generated.ts"); +const webIconSource = NodeFS.readFileSync( + NodePath.join(repositoryRoot, "apps/web/src/pierre-icons.ts"), + "utf8", +); const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1]; if (!customSprite) { @@ -95,20 +98,20 @@ function symbolFromSprite(sprite, id) { } function renderIcon(token, symbol, color) { - const svgPath = join(outputDirectory, `.pierre-${token}.svg`); - const pngPath = join(outputDirectory, `pierre_${token}.png`); - writeFileSync( + const svgPath = NodePath.join(outputDirectory, `.pierre-${token}.svg`); + const pngPath = NodePath.join(outputDirectory, `pierre_${token}.png`); + NodeFS.writeFileSync( svgPath, `${symbol.body}`, ); - execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { + NodeChildProcess.execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { stdio: "ignore", }); - rmSync(svgPath); + NodeFS.rmSync(svgPath); } -rmSync(outputDirectory, { recursive: true, force: true }); -mkdirSync(outputDirectory, { recursive: true }); +NodeFS.rmSync(outputDirectory, { recursive: true, force: true }); +NodeFS.mkdirSync(outputDirectory, { recursive: true }); const builtInSprite = getBuiltInSpriteSheet("complete"); const builtInTokens = [...builtInSprite.matchAll(/ ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) .join("\n")}\n} as const satisfies Readonly>;\n`; -writeFileSync(generatedModulePath, generatedSource); +NodeFS.writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 757b6c66011..212c385124e 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -40,11 +40,13 @@ function documentFor(node: MarkdownNode): MarkdownNode { function SelectableNode(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { return ( ); } @@ -312,6 +314,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] { function NativeTable(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { const rows = collectTableRows(props.node); return ( @@ -351,6 +354,7 @@ function NativeTable(props: { rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, )} textStyle={props.textStyle} + onLinkPress={props.onLinkPress} /> ))} @@ -364,10 +368,17 @@ function NativeTable(props: { function NativeMarkdownImage(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { const href = props.node.href; if (!href) { - return ; + return ( + + ); } return ( @@ -426,6 +437,7 @@ function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { function NativeMixedParagraph(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { return ( @@ -435,9 +447,15 @@ function NativeMixedParagraph(props: { key={nodeKey(child, index)} node={child} textStyle={props.textStyle} + onLinkPress={props.onLinkPress} /> ) : ( - + ), )} @@ -448,6 +466,7 @@ function NativeList(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly onLinkPress?: (href: string) => void; readonly depth: number; }) { const ordered = props.node.ordered ?? false; @@ -508,6 +527,7 @@ function NativeList(props: { node={child} textStyle={props.textStyle} highlightCode={props.highlightCode} + onLinkPress={props.onLinkPress} depth={props.depth + 1} compact /> @@ -524,6 +544,7 @@ export function NativeMarkdownBlock(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly onLinkPress?: (href: string) => void; readonly depth?: number; readonly compact?: boolean; }) { @@ -538,6 +559,7 @@ export function NativeMarkdownBlock(props: { node={child} textStyle={props.textStyle} highlightCode={props.highlightCode} + onLinkPress={props.onLinkPress} depth={depth} /> ))} @@ -553,9 +575,21 @@ export function NativeMarkdownBlock(props: { /> ); case "table": - return ; + return ( + + ); case "image": - return ; + return ( + + ); case "horizontal_rule": return ( @@ -595,14 +630,23 @@ export function NativeMarkdownBlock(props: { node={props.node} textStyle={props.textStyle} highlightCode={props.highlightCode} + onLinkPress={props.onLinkPress} depth={depth} /> ); case "paragraph": return (props.node.children ?? []).some((child) => child.type === "image") ? ( - + ) : ( - + ); case "html_block": case "math_block": @@ -618,7 +662,11 @@ export function NativeMarkdownBlock(props: { : "transparent", }} > - + ); case "table_head": @@ -635,6 +683,7 @@ export function NativeMarkdownBlock(props: { node={child} textStyle={props.textStyle} highlightCode={props.highlightCode} + onLinkPress={props.onLinkPress} depth={depth} compact /> @@ -642,6 +691,12 @@ export function NativeMarkdownBlock(props: { ); default: - return ; + return ( + + ); } } diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index c6495eed860..c7a5a16d6fd 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -1,61 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; -import { Asset } from "expo-asset"; import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; -import type { MarkdownFileIcon } from "./markdownLinks"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; const EXTERNAL_LINK_PREFIX = "◉ "; -const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; -const CHIP_SUFFIX = "\u00A0"; +const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; const SKILL_ICON_PLACEHOLDER = "\uFFFC"; const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; -function useFileIconUris(runs: ReadonlyArray) { - const iconSignature = JSON.stringify( - [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(), - ); - const icons = useMemo( - () => JSON.parse(iconSignature) as ReadonlyArray, - [iconSignature], - ); - const [uris, setUris] = useState>(() => new Map()); - - useEffect(() => { - let cancelled = false; - - void Promise.all( - icons.map(async (icon) => { - const source = markdownFileIconSource(icon); - const fallbackUri = Image.resolveAssetSource(source).uri; - if (typeof source !== "number" && typeof source !== "string") { - return [icon, fallbackUri] as const; - } - try { - const asset = Asset.fromModule(source); - await asset.downloadAsync(); - return [icon, asset.localUri ?? fallbackUri] as const; - } catch { - return [icon, fallbackUri] as const; - } - }), - ).then((entries) => { - if (!cancelled) { - setUris(new Map(entries)); - } - }); - - return () => { - cancelled = true; - }; - }, [icons]); - - return uris; -} - function runKeySignature(run: NativeMarkdownTextRun): string { return [ run.text, @@ -81,13 +35,16 @@ function runKeySignature(run: NativeMarkdownTextRun): string { function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { const isFile = run.fileIcon != null; const isSkill = run.skillName != null; - const isChip = isFile || isSkill; const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; const isHeading = run.role === "heading"; const isCodeBlock = run.role === "code-block" || run.role === "code-language"; const hasParagraphStyle = run.headIndent !== undefined; - const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none"; + const textDecorationLine = run.strikethrough + ? "line-through" + : run.href && !isFile + ? "underline" + : "none"; return { color: isFile @@ -106,20 +63,23 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle ? textStyle.mutedColor : run.role === "list-marker" ? textStyle.mutedColor - : run.code || isFile + : isCodeBlock ? textStyle.codeColor - : run.bold - ? textStyle.strongColor - : textStyle.color, - fontFamily: isChip - ? "DMSans_500Medium" - : run.code || isCodeBlock - ? "ui-monospace" - : isHeading - ? textStyle.headingFontFamily - : run.bold - ? textStyle.boldFontFamily - : textStyle.fontFamily, + : run.code + ? textStyle.inlineCodeColor + : run.bold + ? textStyle.strongColor + : textStyle.color, + fontFamily: + isFile || isSkill + ? textStyle.boldFontFamily + : run.code || isCodeBlock + ? "ui-monospace" + : isHeading + ? textStyle.headingFontFamily + : run.bold + ? textStyle.boldFontFamily + : textStyle.fontFamily, fontSize: run.role === "spacer" ? (run.spacing ?? 10) @@ -129,7 +89,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle ? headingFontSize : run.role === "code-language" ? 11 - : run.code || isChip || isCodeBlock + : run.code || isCodeBlock ? Math.max(12, textStyle.fontSize - 2) : textStyle.fontSize, lineHeight: @@ -143,17 +103,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle ? 18 : textStyle.lineHeight, fontStyle: run.italic ? "italic" : "normal", - fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400", + fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", textDecorationLine, - backgroundColor: isCodeBlock - ? textStyle.codeBlockBackgroundColor - : isSkill - ? textStyle.skillBackgroundColor - : run.code - ? textStyle.codeBackgroundColor - : isFile - ? textStyle.fileBackgroundColor - : undefined, + backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined, ...(hasParagraphStyle ? { shadowColor: "transparent", @@ -170,9 +122,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle export function NativeMarkdownSelectableText(props: { readonly runs: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { const colorScheme = useColorScheme(); - const fileIconUris = useFileIconUris(props.runs); const occurrences = new Map(); const prefixedExternalLinks = new Set(); const keyedRuns = props.runs.map((run) => { @@ -182,9 +134,9 @@ export function NativeMarkdownSelectableText(props: { let text = run.text; if (run.fileIcon) { - text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`; + text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { - text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`; + text = `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); text = `${EXTERNAL_LINK_PREFIX}${text}`; @@ -200,12 +152,11 @@ export function NativeMarkdownSelectableText(props: { props.textStyle.strongColor, props.textStyle.mutedColor, props.textStyle.linkColor, + props.textStyle.inlineCodeColor, props.textStyle.codeColor, props.textStyle.codeBackgroundColor, props.textStyle.codeBlockBackgroundColor, - props.textStyle.fileBackgroundColor, props.textStyle.fileTextColor, - props.textStyle.skillBackgroundColor, props.textStyle.skillTextColor, props.textStyle.quoteMarkerColor, props.textStyle.dividerColor, @@ -217,7 +168,8 @@ export function NativeMarkdownSelectableText(props: { uiTextView selectable style={{ - width: "100%", + flexShrink: 1, + minWidth: 0, color: props.textStyle.color, fontFamily: props.textStyle.fontFamily, fontSize: props.textStyle.fontSize, @@ -231,19 +183,20 @@ export function NativeMarkdownSelectableText(props: { key={key} nativeID={ run.fileIcon - ? `t3-chip-file:${ - fileIconUris.get(run.fileIcon) ?? - Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri - }` + ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` : run.skillName - ? "t3-chip-skill:sf:cube" + ? "t3-skill:sf:cube" : undefined } style={runStyle(run, props.textStyle)} onPress={ href ? () => { - void Linking.openURL(href); + if (props.onLinkPress) { + props.onLinkPress(href); + } else { + void Linking.openURL(href); + } } : undefined } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 7c8f8d1bd55..56321ba01ad 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -6,6 +6,7 @@ import { nativeMarkdownChunkSpacing, nativeMarkdownDocumentChunks, nativeMarkdownDocumentRuns, + nativeMarkdownWithPreservedSoftBreaks, } from "./nativeMarkdownText"; import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; @@ -33,15 +34,20 @@ export function SelectableMarkdownText({ skills = EMPTY_SKILLS, textStyle, highlightCode, + preserveSoftBreaks = false, + onLinkPress, marginTop = 0, marginBottom = 0, }: SelectableMarkdownTextProps) { const chunks = useMemo(() => { - const document = parseMarkdownWithOptions(markdown, { + const parsedDocument = parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false, }); + const document = preserveSoftBreaks + ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) + : parsedDocument; return nativeMarkdownDocumentChunks(document).map((chunk) => chunk.kind === "selectable" ? { @@ -50,10 +56,14 @@ export function SelectableMarkdownText({ } : chunk, ); - }, [markdown, skills]); + }, [markdown, preserveSoftBreaks, skills]); return ( - + // A percentage width here creates a cyclic intrinsic measurement inside + // shrink-to-fit containers such as user-message bubbles. Yoga then gives + // the native text node an unbounded second pass and the parent only clips + // the resulting single-line width instead of reflowing it. + {chunks.map((chunk, index) => { const content = chunk.kind === "rich" ? ( @@ -61,9 +71,14 @@ export function SelectableMarkdownText({ node={chunk.node} textStyle={textStyle} highlightCode={highlightCode} + onLinkPress={onLinkPress} /> ) : ( - + ); return ( diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index bd67d9110e5..76c1402d3c8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -3,12 +3,11 @@ export interface NativeMarkdownTextStyle { readonly strongColor: string; readonly mutedColor: string; readonly linkColor: string; + readonly inlineCodeColor: string; readonly codeColor: string; readonly codeBackgroundColor: string; readonly codeBlockBackgroundColor: string; - readonly fileBackgroundColor: string; readonly fileTextColor: string; - readonly skillBackgroundColor: string; readonly skillTextColor: string; readonly quoteMarkerColor: string; readonly dividerColor: string; @@ -41,6 +40,8 @@ export interface SelectableMarkdownTextProps { readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly skills?: ReadonlyArray; + readonly preserveSoftBreaks?: boolean; + readonly onLinkPress?: (href: string) => void; readonly marginTop?: number; readonly marginBottom?: number; } diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index affd7515b25..f13891e3ff8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -27,8 +27,12 @@ export type MarkdownLinkPresentation = } | { readonly kind: "file"; + readonly href: string; readonly icon: MarkdownFileIcon; readonly label: string; + readonly path: string; + readonly line?: number; + readonly column?: number; } | { readonly kind: "link"; @@ -247,7 +251,7 @@ function normalizeDestination(value: string): string { return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; } -function fileUrlPath(href: string): string | null { +function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") { @@ -256,15 +260,44 @@ function fileUrlPath(href: string): string | null { const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) ? parsed.pathname.slice(1) : parsed.pathname; - const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i); - return `${safeDecode(path)}${ - lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : "" - }`; + return { path, hash: parsed.hash }; } catch { return null; } } +function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } { + const hashIndex = value.indexOf("#"); + const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; + const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; + const queryIndex = pathWithSearch.indexOf("?"); + return { + path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, + hash, + }; +} + +function splitFilePosition( + path: string, + hash: string, +): { readonly path: string; readonly line?: number; readonly column?: number } { + const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/); + const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i); + const match = suffixMatch ?? hashMatch; + if (!match?.[1]) { + return { path }; + } + + const line = Number.parseInt(match[1], 10); + const column = match[2] ? Number.parseInt(match[2], 10) : undefined; + const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path; + return { + path: pathWithoutPosition, + ...(line > 0 ? { line } : {}), + ...(column !== undefined && column > 0 ? { column } : {}), + }; +} + function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) { return false; @@ -331,14 +364,31 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese // Relative paths and non-URL link destinations are handled below. } - const fileTarget = normalized.toLowerCase().startsWith("file:") - ? fileUrlPath(normalized) - : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized); - if (fileTarget && looksLikeFilePath(fileTarget)) { + const source = normalized.toLowerCase().startsWith("file:") + ? fileUrlTarget(normalized) + : stripSearchAndHash(normalized); + const decodedSource = source + ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) } + : null; + const fileTarget = decodedSource + ? splitFilePosition(decodedSource.path, decodedSource.hash) + : null; + const targetWithPosition = fileTarget + ? `${fileTarget.path}${ + fileTarget.line + ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}` + : "" + }` + : null; + if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) { return { kind: "file", - icon: resolveMarkdownFileIcon(fileTarget), - label: fileLabel(fileTarget), + href: normalized, + icon: resolveMarkdownFileIcon(fileTarget.path), + label: fileLabel(targetWithPosition), + path: fileTarget.path, + ...(fileTarget.line ? { line: fileTarget.line } : {}), + ...(fileTarget.column ? { column: fileTarget.column } : {}), }; } diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 6751e165f1c..dc84755cbbd 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -293,6 +293,7 @@ function appendNode( if (presentation.kind === "file") { return appendRun(runs, presentation.label, { ...context, + href: presentation.href, fileIcon: presentation.icon, }); } @@ -319,6 +320,15 @@ export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray CGFloat { + guard let value, value.isFinite, value >= 0 else { + return fallback + } + return CGFloat(value) + } + private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight { switch value?.lowercased() { case "ultralight", "ultra-light": @@ -316,6 +323,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private var lastMetricsDebugKey = "" private var lastVisibleRangeDebugKey = "" private var tokensResetKey = "" + private var initialRowIndex: Int? + private var hasAppliedInitialRowIndex = false let onDebug = EventDispatcher() let onToggleFile = EventDispatcher() @@ -394,6 +403,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { do { rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) contentView.rows = rows + hasAppliedInitialRowIndex = false emitDebug("rows-decoded", [ "rows": rows.count, "firstKind": rows.first?.kind ?? "none", @@ -402,6 +412,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } catch { rows = [] contentView.rows = [] + hasAppliedInitialRowIndex = false updateContentMetrics() emitDebug("rows-decode-failed", [ "error": error.localizedDescription, @@ -561,6 +572,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() contentView.setNeedsDisplay() + applyInitialRowIndexIfNeeded() let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" if debugKey != lastMetricsDebugKey { @@ -645,6 +657,19 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { applyStyle() } + func setInitialRowIndex(_ initialRowIndex: Double) { + let nextIndex: Int? = initialRowIndex.isFinite && initialRowIndex >= 0 + ? Int(initialRowIndex.rounded(.down)) + : nil + guard nextIndex != self.initialRowIndex else { + return + } + + self.initialRowIndex = nextIndex + hasAppliedInitialRowIndex = false + applyInitialRowIndexIfNeeded() + } + private func applyStyle() { contentView.style = ReviewDiffNativeStyle .resolve(stylePayload) @@ -662,6 +687,22 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() } + + private func applyInitialRowIndexIfNeeded() { + guard !hasAppliedInitialRowIndex, + let initialRowIndex, + bounds.height > 0, + let rowFrame = contentView.frameForRow(at: initialRowIndex) else { + return + } + + let targetScreenY = max(0, (bounds.height - rowFrame.height) * 0.3) + let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) + let targetOffset = min(max(rowFrame.minY - targetScreenY, 0), maxOffset) + hasAppliedInitialRowIndex = true + scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false) + updateViewportFrame() + } } private enum ReviewDiffHorizontalPanKind { @@ -820,6 +861,19 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return style.rowHeight } + func frameForRow(at index: Int) -> CGRect? { + guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { + return nil + } + + return CGRect( + x: 0, + y: rowOffsets[index], + width: max(viewportWidth, 1), + height: height(for: rows[index]) + ) + } + private func rebuildRowLayout() { var nextOffsets: [CGFloat] = [] var nextFileHeaderRowIndices: [Int] = [] diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f0a4dc2a905..963cefd6b7c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,11 +40,11 @@ }, "dependencies": { "@callstack/liquid-glass": "^0.7.1", - "@clerk/expo": "^3.4.1", + "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", - "@legendapp/list": "3.0.0-beta.44", + "@legendapp/list": "3.2.0", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", @@ -77,6 +77,7 @@ "expo-haptics": "~56.0.3", "expo-image-picker": "~56.0.14", "expo-linking": "~56.0.12", + "expo-network": "~56.0.5", "expo-notifications": "~56.0.14", "expo-paste-input": "^0.1.15", "expo-router": "~56.2.7", @@ -92,14 +93,15 @@ "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-image-viewing": "^0.2.2", - "react-native-keyboard-controller": "1.21.6", + "react-native-keyboard-controller": "1.21.7", "react-native-nitro-markdown": "^0.5.0", - "react-native-nitro-modules": "^0.35.4", + "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.4", + "react-native-webview": "^13.16.1", "react-native-worklets": "0.8.3", "shiki": "4.2.0", "tailwind-merge": "^3.5.0", diff --git a/apps/mobile/src/app/+not-found.tsx b/apps/mobile/src/app/+not-found.tsx index 124077b0909..d11155f8602 100644 --- a/apps/mobile/src/app/+not-found.tsx +++ b/apps/mobile/src/app/+not-found.tsx @@ -21,7 +21,7 @@ export default function NotFoundRoute() { }} style={[{ flex: 1 }, screenBgStyle]} > - + Route not found @@ -35,7 +35,7 @@ export default function NotFoundRoute() { primaryBgStyle, ]} > - Return home + Return home diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 1583fdbb2d7..968be6c14a8 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -16,10 +16,8 @@ import { useResolveClassNames } from "uniwind"; import { LoadingScreen } from "../components/LoadingScreen"; -import { - useRemoteEnvironmentBootstrap, - useRemoteEnvironmentState, -} from "../state/use-remote-environment-registry"; +import { useWorkspaceState } from "../state/workspace"; +import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain"; import { RegistryContext } from "@effect/atom-react"; import { appAtomRegistry } from "../state/atom-registry"; import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; @@ -32,22 +30,24 @@ import { useThemeColor } from "../lib/useThemeColor"; function AppNavigator() { const pathname = usePathname(); - const clerkRouteIsActive = pathname === "/settings/auth"; + const expandedSettingsRouteIsActive = + pathname === "/settings/archive" || pathname === "/settings/auth"; return ( - + ); } function AppNavigatorContent() { - const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const { state } = useWorkspaceState(); const { collapse, isExpanded } = useClerkSettingsSheetDetent(); const colorScheme = useColorScheme(); const statusBarBg = useThemeColor("--color-status-bar"); const sheetStyle = useResolveClassNames("bg-sheet"); useAgentNotificationNavigation(); + useThreadOutboxDrain(); const handleSettingsTransitionEnd = useCallback( (event: { data: { closing: boolean } }) => { @@ -81,7 +81,7 @@ function AppNavigatorContent() { sheetAllowedDetents: isExpanded ? [0.92] : [0.7], }; - if (isLoadingSavedConnection) { + if (state.isLoadingConnections) { return ; } @@ -129,8 +129,6 @@ export default function RootLayout() { DMSans_500Medium, DMSans_700Bold, }); - useRemoteEnvironmentBootstrap(); - return ( diff --git a/apps/mobile/src/app/connections/index.tsx b/apps/mobile/src/app/connections/index.tsx index 5db76f1c6b1..12a06996447 100644 --- a/apps/mobile/src/app/connections/index.tsx +++ b/apps/mobile/src/app/connections/index.tsx @@ -85,7 +85,7 @@ export default function ConnectionsRouteScreen() { type="monochrome" /> - + No environments connected yet.{"\n"}Tap{" "} + to add one. diff --git a/apps/mobile/src/app/connections/new.tsx b/apps/mobile/src/app/connections/new.tsx index 566c038cc24..ca9693dbb19 100644 --- a/apps/mobile/src/app/connections/new.tsx +++ b/apps/mobile/src/app/connections/new.tsx @@ -1,5 +1,6 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useState } from "react"; import { Alert, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -111,12 +112,12 @@ export default function ConnectionsNewRouteScreen() { const handleSubmit = useCallback(async () => { setIsSubmitting(true); - try { - const pairingUrl = buildPairingUrl(hostInput, codeInput); - onChangeConnectionPairingUrl(pairingUrl); - await onConnectPress(pairingUrl); + const pairingUrl = buildPairingUrl(hostInput, codeInput); + onChangeConnectionPairingUrl(pairingUrl); + const result = await onConnectPress(pairingUrl); + if (AsyncResult.isSuccess(result)) { dismissRoute(router); - } catch { + } else { setIsSubmitting(false); } }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); @@ -170,7 +171,7 @@ export default function ConnectionsNewRouteScreen() { className="items-center gap-3 rounded-[24px] bg-card px-5 py-8" style={{ borderCurve: "continuous" }} > - + Camera permission is required to scan a QR code. Host @@ -201,13 +202,13 @@ export default function ConnectionsNewRouteScreen() { placeholderTextColor={placeholderColor} value={hostInput} onChangeText={handleHostChange} - className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground" + className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" /> Pairing code @@ -219,7 +220,7 @@ export default function ConnectionsNewRouteScreen() { placeholderTextColor={placeholderColor} value={codeInput} onChangeText={handleCodeChange} - className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground" + className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" /> diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index c2b94dd9097..7f9962efc98 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,112 +1,119 @@ -import { Stack, useRouter } from "expo-router"; -import { useState } from "react"; -import { Text as RNText, View } from "react-native"; +import type { + EnvironmentId, + SidebarProjectGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import { + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +} from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; +import { useRouter } from "expo-router"; +import { useCallback, useMemo, useState } from "react"; +import { useProjects, useThreadShells } from "../state/entities"; +import { useWorkspaceState } from "../state/workspace"; import { buildThreadRoutePath } from "../lib/routes"; -import { useRemoteCatalog } from "../state/use-remote-catalog"; -import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry"; +import { useSavedRemoteConnections } from "../state/use-remote-environment-registry"; import { HomeScreen } from "../features/home/HomeScreen"; -import { useThemeColor } from "../lib/useThemeColor"; +import { HomeHeader } from "../features/home/HomeHeader"; +import type { HomeProjectSortOrder } from "../features/home/homeThreadList"; +import { useThreadListActions } from "../features/home/useThreadListActions"; + +interface HomeListOptions { + readonly selectedEnvironmentId: EnvironmentId | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; +} /* ─── Route screen ───────────────────────────────────────────────────── */ export default function HomeRouteScreen() { - const { projects, state: catalogState, threads } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const projects = useProjects(); + const threads = useThreadShells(); + const { state: catalogState } = useWorkspaceState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const router = useRouter(); const [searchQuery, setSearchQuery] = useState(""); - - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const subtleColor = useThemeColor("--color-subtle"); + const [listOptions, setListOptions] = useState({ + selectedEnvironmentId: null, + projectSortOrder: + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" + ? "updated_at" + : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, + projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + }); + const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const environments = useMemo( + () => + Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + })), + Order.mapInput( + Order.String, + (environment: { readonly label: string }) => environment.label, + ), + ), + [savedConnectionsById], + ); + const selectedEnvironmentId = environments.some( + (environment) => environment.environmentId === listOptions.selectedEnvironmentId, + ) + ? listOptions.selectedEnvironmentId + : null; + const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => { + setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId })); + }, []); + const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => { + setListOptions((current) => ({ ...current, projectSortOrder })); + }, []); + const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => { + setListOptions((current) => ({ ...current, threadSortOrder })); + }, []); + const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => { + setListOptions((current) => ({ ...current, projectGroupingMode })); + }, []); return ( <> - { - setSearchQuery(event.nativeEvent.text); - }, - allowToolbarIntegration: true, - }, - }} + router.push("/settings")} + onProjectGroupingModeChange={setProjectGroupingMode} + onProjectSortOrderChange={setProjectSortOrder} + onSearchQueryChange={setSearchQuery} + onStartNewTask={() => router.push("/new")} + onThreadSortOrderChange={setThreadSortOrder} /> - {/* Header left: plain text, no Liquid Glass button chrome */} - - - - - T3 Code - - - - Alpha - - - - - - - - router.push("/settings")} - separateBackground - /> - - - {/* Bottom toolbar: search + compose, visually split like iMessage */} - - - - router.push("/new")} - separateBackground - /> - - router.push("/connections/new")} + onArchiveThread={archiveThread} + onDeleteThread={confirmDeleteThread} + onOpenEnvironments={() => router.push("/settings/environments")} onSelectThread={(thread) => { router.push(buildThreadRoutePath(thread)); }} + projectGroupingMode={listOptions.projectGroupingMode} + projects={projects} + projectSortOrder={listOptions.projectSortOrder} + savedConnectionsById={savedConnectionsById} + searchQuery={searchQuery} + selectedEnvironmentId={selectedEnvironmentId} + threads={threads} + threadSortOrder={listOptions.threadSortOrder} /> ); diff --git a/apps/mobile/src/app/new/add-project/repository.tsx b/apps/mobile/src/app/new/add-project/repository.tsx index 2861dded1ad..7bf23a4955a 100644 --- a/apps/mobile/src/app/new/add-project/repository.tsx +++ b/apps/mobile/src/app/new/add-project/repository.tsx @@ -1,5 +1,5 @@ import { Stack, useLocalSearchParams } from "expo-router"; -import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime"; +import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; diff --git a/apps/mobile/src/app/new/index.tsx b/apps/mobile/src/app/new/index.tsx index 76102d842f4..6e2aa64ce11 100644 --- a/apps/mobile/src/app/new/index.tsx +++ b/apps/mobile/src/app/new/index.tsx @@ -8,16 +8,17 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { useProjects, useThreadShells } from "../../state/entities"; +import type { WorkspaceState } from "../../state/workspaceModel"; +import { useWorkspaceState } from "../../state/workspace"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; -import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; -function deriveProjectEmptyState(catalogState: RemoteCatalogState): { +function deriveProjectEmptyState(catalogState: WorkspaceState): { readonly title: string; readonly detail: string; readonly loading: boolean; } { - if (catalogState.isLoadingSavedConnections) { + if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", @@ -25,7 +26,7 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { }; } - if (!catalogState.hasSavedConnections) { + if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment before creating a task.", @@ -33,7 +34,12 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { }; } - if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error") && + !catalogState.hasLoadedShellSnapshot + ) { return { title: "Environment unavailable", detail: @@ -63,8 +69,9 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { } export default function NewTaskRoute() { - const { projects, state: catalogState, threads } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const projects = useProjects(); + const threads = useThreadShells(); + const { state: catalogState } = useWorkspaceState(); const router = useRouter(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); @@ -122,10 +129,10 @@ export default function NewTaskRoute() { {items.length === 0 ? ( {projectEmptyState.loading ? : null} - + {projectEmptyState.title} - + {projectEmptyState.detail} {!catalogState.hasReadyEnvironment ? ( @@ -133,7 +140,7 @@ export default function NewTaskRoute() { className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" onPress={() => router.push("/connections/new")} > - + Add environment @@ -142,7 +149,7 @@ export default function NewTaskRoute() { className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" onPress={() => router.push("/new/add-project")} > - + Add new project @@ -183,21 +190,14 @@ export default function NewTaskRoute() { - - {item.title} - + {item.title} { if (event.data.closing) { collapse(); @@ -47,9 +47,14 @@ export default function SettingsLayout() { name="waitlist" options={{ animation: "slide_from_right", title: "Join the waitlist" }} /> + diff --git a/apps/mobile/src/app/settings/archive.tsx b/apps/mobile/src/app/settings/archive.tsx new file mode 100644 index 00000000000..2b900afbbce --- /dev/null +++ b/apps/mobile/src/app/settings/archive.tsx @@ -0,0 +1,3 @@ +import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen"; + +export default ArchivedThreadsRouteScreen; diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index 8a40720089b..8f65c630a54 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -1,32 +1,38 @@ import { useAuth } from "@clerk/expo"; import { Stack, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; +import { + connectionStatusText, + type EnvironmentConnectionPhase, +} from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; -import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; -import * as Effect from "effect/Effect"; -import { useCallback, useMemo, useState } from "react"; -import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { useCallback, useState } from "react"; +import { + ActivityIndicator, + Pressable, + ScrollView, + Switch, + type NativeSyntheticEvent, + type TextLayoutEventData, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; -import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment"; import { - hasCloudPublicConfig, - resolveRelayClerkTokenOptions, -} from "../../features/cloud/publicConfig"; -import { - useManagedRelayEnvironments, - useManagedRelayEnvironmentStatus, -} from "../../features/cloud/managedRelayState"; + type RelayEnvironmentView, + useConnectionController, +} from "../../features/connection/useConnectionController"; +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation"; import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; +import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot"; +import { splitEnvironmentSections } from "../../features/connection/environmentSections"; import { cn } from "../../lib/cn"; -import { mobileRuntime } from "../../lib/runtime"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; -import { - connectSavedEnvironment, - useRemoteConnections, - useRemoteEnvironmentState, -} from "../../state/use-remote-environment-registry"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { useRemoteConnections } from "../../state/use-remote-environment-registry"; export default function SettingsEnvironmentsRouteScreen() { const { @@ -37,7 +43,11 @@ export default function SettingsEnvironmentsRouteScreen() { } = useRemoteConnections(); const router = useRouter(); const insets = useSafeAreaInsets(); - const hasEnvironments = connectedEnvironments.length > 0; + const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ + connectedEnvironments, + cloudEnvironments: null, + }); + const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); const accentColor = useThemeColor("--color-icon-muted"); @@ -69,9 +79,9 @@ export default function SettingsEnvironmentsRouteScreen() { paddingTop: 16, }} > - {hasEnvironments ? ( + {hasLocalEnvironments ? ( - {connectedEnvironments.map((environment, index) => ( + {localEnvironments.map((environment, index) => ( - + No environments connected yet.{"\n"}Tap{" "} + to add one. )} - {hasCloudPublicConfig() ? : null} + {hasCloudPublicConfig() ? ( + + ) : null} ); } -function ConfiguredCloudEnvironmentRows() { - const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { savedConnectionsById } = useRemoteEnvironmentState(); - const cloudEnvironmentsState = useManagedRelayEnvironments(); - const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState( - null, - ); +function ConfiguredCloudEnvironmentRows(props: { + readonly connectedCloudEnvironments: ReadonlyArray; + readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; +}) { + const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = useMemo( - () => - (cloudEnvironmentsState.data ?? []).filter( - (environment) => savedConnectionsById[environment.environmentId] === undefined, - ), - [cloudEnvironmentsState.data, savedConnectionsById], - ); + const availableCloudEnvironments = controller.availableRelayEnvironments; + const [expandedErrorId, setExpandedErrorId] = useState(null); + const hasCloudRows = + props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; const handleConnectCloudEnvironment = useCallback( - async (environment: RelayClientEnvironmentRecord) => { - setConnectingCloudEnvironmentId(environment.environmentId); - try { - const token = await getToken(resolveRelayClerkTokenOptions()); - if (!token) { - throw new Error("Sign in to T3 Cloud before connecting."); - } - await mobileRuntime.runPromise( - connectCloudEnvironment({ - clerkToken: token, - environment, - }).pipe(Effect.flatMap(connectSavedEnvironment)), - ); - } catch (error) { - Alert.alert( - "Connect failed", - error instanceof Error ? error.message : "Could not connect to this environment.", - ); - } finally { - setConnectingCloudEnvironmentId(null); - } - }, - [getToken], + (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), + [controller], ); + const handleDisconnectCloudEnvironment = useCallback( + (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), + [controller], + ); + + const handleToggleCloudError = useCallback((environmentId: string) => { + setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); + }, []); + if (!isSignedIn) return null; return ( - T3 Cloud + T3 Cloud { + void controller.refreshRelayEnvironments(); + }} className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" > - {cloudEnvironmentsState.isPending ? ( + {controller.relayDiscovery.isRefreshing ? ( ) : ( @@ -176,37 +177,52 @@ function ConfiguredCloudEnvironmentRows() { - {availableCloudEnvironments.length > 0 ? ( + {hasCloudRows ? ( - {availableCloudEnvironments.map((environment, index) => ( - ( + props.onReconnectEnvironment(environment.environmentId)} + onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} + errorExpanded={expandedErrorId === environment.environmentId} + onToggleError={() => handleToggleCloudError(environment.environmentId)} + /> + ))} + {availableCloudEnvironments.map((environment, index) => ( + 0 || index !== 0} onConnect={() => handleConnectCloudEnvironment(environment)} + errorExpanded={expandedErrorId === environment.environment.environmentId} + onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} /> ))} - ) : cloudEnvironmentsState.data === null ? ( + ) : controller.relayDiscovery.isRefreshing ? ( - + Loading linked cloud environments. - ) : cloudEnvironmentsState.error ? ( + ) : controller.relayDiscovery.error ? ( - + Could not load T3 Cloud environments - - {cloudEnvironmentsState.error} + + {controller.relayDiscovery.error} + {controller.relayDiscovery.errorTraceId ? ( + + ) : null} ) : ( - + No additional linked cloud environments. @@ -215,23 +231,124 @@ function ConfiguredCloudEnvironmentRows() { ); } +function ConnectedCloudEnvironmentRow(props: { + readonly environment: ConnectedEnvironmentSummary; + readonly borderTop: boolean; + readonly errorExpanded: boolean; + readonly onConnect: () => void; + readonly onDisconnect: () => void; + readonly onToggleError: () => void; +}) { + return ( + { + if (enabled) { + props.onConnect(); + return; + } + props.onDisconnect(); + }} + onToggleError={props.onToggleError} + value={props.environment.connectionState !== "available"} + /> + ); +} + function CloudEnvironmentRow(props: { - readonly environment: RelayClientEnvironmentRecord; + readonly environment: RelayEnvironmentView; readonly borderTop: boolean; - readonly isConnecting: boolean; + readonly errorExpanded: boolean; readonly onConnect: () => void; + readonly onToggleError: () => void; }) { - const mutedColor = useThemeColor("--color-icon-muted"); - const statusState = useManagedRelayEnvironmentStatus(props.environment); - const status = statusState.data; - const disabled = props.isConnecting; - const statusText = - status === null - ? (statusState.error ?? (statusState.isPending ? "Checking status..." : "Status unavailable")) - : status.status === "online" - ? "Online" - : (status.error ?? "Offline"); + const presentation = availableCloudEnvironmentPresentation({ + isStatusPending: props.environment.availability === "checking", + status: props.environment.status, + statusError: props.environment.error, + statusErrorTraceId: props.environment.traceId, + }); + return ( + { + if (enabled) { + props.onConnect(); + } + }} + onToggleError={props.onToggleError} + statusText={presentation.statusText} + value={false} + /> + ); +} + +function CloudEnvironmentRowShell(props: { + readonly borderTop: boolean; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly disabled?: boolean; + readonly errorExpanded: boolean; + readonly label: string; + readonly onToggleError: () => void; + readonly onValueChange: (enabled: boolean) => void; + readonly statusText?: string; + readonly value: boolean; +}) { + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + const chevron = useThemeColor("--color-chevron"); + const isRetrying = + props.connectionState === "connecting" || props.connectionState === "reconnecting"; + const shouldPulse = isRetrying; + const statusText = + props.statusText ?? + connectionStatusText({ + phase: props.connectionState, + error: props.connectionError, + traceId: props.connectionErrorTraceId, + }); + const statusClassName = props.connectionError + ? "text-rose-500 dark:text-rose-400" + : "text-foreground-muted"; + const [errorMeasurement, setErrorMeasurement] = useState<{ + readonly text: string; + readonly lineCount: number; + } | null>(null); + const errorTraceId = props.connectionErrorTraceId; + const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; + const errorLineCount = + errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; + const errorCanExpand = props.connectionError !== null && errorLineCount > 1; + const isErrorExpanded = errorCanExpand && props.errorExpanded; + const StatusContainer = errorCanExpand ? Pressable : View; + const onMeasuredErrorTextLayout = useCallback( + (event: NativeSyntheticEvent) => { + if (!props.connectionError) { + return; + } + const nextLineCount = event.nativeEvent.lines.length; + setErrorMeasurement((currentMeasurement) => + currentMeasurement?.text === measuredErrorText && + currentMeasurement.lineCount === nextLineCount + ? currentMeasurement + : { text: measuredErrorText, lineCount: nextLineCount }, + ); + }, + [measuredErrorText, props.connectionError], + ); return ( - - - - - {props.environment.label} - - - {props.environment.endpoint.httpBaseUrl} - - - {statusText} - + + + + {props.label} + + + {props.connectionError ? ( + + {measuredErrorText} + + ) : null} + + + {statusText} + {errorTraceId ? ( + <> + {" Trace ID: "} + { + event.stopPropagation(); + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); + }} + onPress={(event) => { + event.stopPropagation(); + }} + style={{ textDecorationStyle: "dotted" }} + > + {errorTraceId} + + + ) : null} + + {errorCanExpand ? ( + + ) : null} + - - - {props.isConnecting ? "Connecting" : "Connect"} - - + ); } + +function CopyTraceIdButton(props: { readonly traceId: string }) { + const iconColor = useThemeColor("--color-icon"); + + return ( + { + copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); + }} + className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" + > + + Copy trace ID + + ); +} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index c8b4cd40995..41799ae7b8b 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -8,6 +8,13 @@ import type { ComponentProps, ReactNode } from "react"; import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + isAtomCommandInterrupted, + reportAtomCommandResult, + settleAsyncResult, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { AppText as Text } from "../../components/AppText"; import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; @@ -18,10 +25,10 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions, } from "../../features/cloud/publicConfig"; -import { mobileRuntime } from "../../lib/runtime"; +import { runtime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -32,7 +39,7 @@ export default function SettingsRouteScreen() { function LocalSettingsRouteScreen() { const insets = useSafeAreaInsets(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const environmentCount = Object.keys(savedConnectionsById).length; return ( @@ -58,6 +65,8 @@ function LocalSettingsRouteScreen() { /> + + @@ -70,7 +79,7 @@ function ConfiguredSettingsRouteScreen() { const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); @@ -87,8 +96,13 @@ function ConfiguredSettingsRouteScreen() { setNotificationStatus("unsupported"); return; } - const permission = await Notifications.getPermissionsAsync(); - setNotificationStatus(permission.granted ? "enabled" : "disabled"); + const result = await settlePromise(() => Notifications.getPermissionsAsync()); + if (result._tag === "Failure") { + reportAtomCommandResult(result, { label: "notification permission refresh" }); + setNotificationStatus("disabled"); + return; + } + setNotificationStatus(result.value.granted ? "enabled" : "disabled"); }, []); useEffect(() => { @@ -104,60 +118,66 @@ function ConfiguredSettingsRouteScreen() { setLiveActivityStatus("signed-out"); return; } - void loadPreferences().then( - (preferences) => { - setLiveActivityStatus(preferences.liveActivitiesEnabled === false ? "disabled" : "enabled"); - }, - () => { + void (async () => { + const result = await settlePromise(() => loadPreferences()); + if (result._tag === "Failure") { + reportAtomCommandResult(result, { label: "live activity preference load" }); setLiveActivityStatus("enabled"); - }, - ); + return; + } + setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); + })(); }, [isLoaded, isSignedIn]); const requestNotifications = useCallback(async () => { - try { - const result = await mobileRuntime.runPromise( + const result = await settleAsyncResult(() => + runtime.runPromiseExit( requestAgentNotificationPermission.pipe( Effect.tap((permission) => permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, ), ), - ); - if (result.type === "granted") { - setNotificationStatus("enabled"); - Alert.alert( - "Notifications enabled", - "Live Activity notifications are enabled for this device.", - ); - return; - } - if (result.type === "unsupported") { - setNotificationStatus("unsupported"); + ), + ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); Alert.alert( "Notifications unavailable", - "Live Activity notifications are only available on iOS.", + error instanceof Error ? error.message : "Could not request notification permission.", ); - return; - } - setNotificationStatus("disabled"); - if (result.canAskAgain) { - Alert.alert("Notifications disabled", "Notifications were not enabled."); - return; } + return; + } + if (result.value.type === "granted") { + setNotificationStatus("enabled"); Alert.alert( - "Notifications disabled", - "Notifications were denied for this app. Open Settings to enable them.", - [ - { text: "Cancel", style: "cancel" }, - { text: "Open Settings", onPress: () => void Linking.openSettings() }, - ], + "Notifications enabled", + "Live Activity notifications are enabled for this device.", ); - } catch (error) { + return; + } + if (result.value.type === "unsupported") { + setNotificationStatus("unsupported"); Alert.alert( "Notifications unavailable", - error instanceof Error ? error.message : "Could not request notification permission.", + "Live Activity notifications are only available on iOS.", ); + return; + } + setNotificationStatus("disabled"); + if (result.value.canAskAgain) { + Alert.alert("Notifications disabled", "Notifications were not enabled."); + return; } + Alert.alert( + "Notifications disabled", + "Notifications were denied for this app. Open Settings to enable them.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); }, []); const promptSignIn = useCallback(() => { @@ -178,36 +198,51 @@ function ConfiguredSettingsRouteScreen() { } setLiveActivityStatus("linking"); - try { - const token = await getToken(resolveRelayClerkTokenOptions()); - if (!token) { - promptSignIn(); - setLiveActivityStatus("signed-out"); - return; - } + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + if (tokenResult._tag === "Failure") { + setLiveActivityStatus("disabled"); + const error = squashAtomCommandFailure(tokenResult); + Alert.alert( + "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable Live Activity updates.", + ); + return; + } + if (!tokenResult.value) { + promptSignIn(); + setLiveActivityStatus("signed-out"); + return; + } - await mobileRuntime.runPromise( + const updateResult = await settleAsyncResult(() => + runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, - clerkToken: token, + clerkToken: tokenResult.value, connections, }), - ); - refreshManagedRelayEnvironments(); - setLiveActivityStatus("enabled"); - Alert.alert( - "Live Activities enabled", - environmentCount > 0 - ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` - : "Live Activity updates are enabled. Add an environment to start receiving updates.", - ); - } catch (error) { + ), + ); + if (updateResult._tag === "Failure") { setLiveActivityStatus("disabled"); - Alert.alert( - "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable Live Activity updates.", - ); + if (!isAtomCommandInterrupted(updateResult)) { + const error = squashAtomCommandFailure(updateResult); + Alert.alert( + "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable Live Activity updates.", + ); + } + return; } + + refreshManagedRelayEnvironments(); + setLiveActivityStatus("enabled"); + Alert.alert( + "Live Activities enabled", + environmentCount > 0 + ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` + : "Live Activity updates are enabled. Add an environment to start receiving updates.", + ); }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); const handleDeviceNotificationsChange = useCallback( @@ -234,19 +269,36 @@ function ConfiguredSettingsRouteScreen() { if (!enabled) { setLiveActivityStatus("disabled"); void (async () => { - try { - const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null; - await mobileRuntime.runPromise( + let token: string | null = null; + if (isSignedIn) { + const tokenResult = await settlePromise(() => + getToken(resolveRelayClerkTokenOptions()), + ); + if (tokenResult._tag === "Failure") { + reportAtomCommandResult(tokenResult, { + label: "live activity disable token lookup", + }); + return; + } + token = tokenResult.value; + } + + const updateResult = await settleAsyncResult(() => + runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: token, connections, }), - ); - refreshManagedRelayEnvironments(); - } catch { - // The switch is optimistic; a future refresh reconciles relay state. + ), + ); + if (updateResult._tag === "Failure") { + reportAtomCommandResult(updateResult, { + label: "live activity disable", + }); + return; } + refreshManagedRelayEnvironments(); })(); return; } @@ -294,7 +346,7 @@ function ConfiguredSettingsRouteScreen() { onPress={openAccount} /> - + T3 Code works locally without signing in. Cloud features are optional. @@ -324,6 +376,8 @@ function ConfiguredSettingsRouteScreen() { /> + + @@ -335,7 +389,7 @@ type SymbolName = ComponentProps["name"]; function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { return ( - {props.title} + {props.title} - Version - Alpha + Version + Alpha ); } +function ArchivedThreadsSettingsSection() { + return ( + + + + ); +} + function SettingsRow(props: { readonly disabled?: boolean; readonly icon: SymbolName; readonly label: string; readonly value?: string; - readonly href?: "/settings/environments"; + readonly href?: "/settings/archive" | "/settings/environments"; readonly onPress?: () => void; }) { const icon = useThemeColor("--color-icon"); @@ -382,15 +444,20 @@ function SettingsRow(props: { style={{ opacity: props.disabled ? 0.45 : 1 }} > - {props.label} - {props.value ? ( - - {props.value} - - ) : null} + + {props.label} + + + {props.value ? ( + + {props.value} + + ) : null} + - {content} + + {content} + ); } @@ -433,7 +502,7 @@ function SettingsSwitchRow(props: { style={{ opacity: props.disabled ? 0.45 : 1 }} > - {props.label} + {props.label} + + ; +} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx new file mode 100644 index 00000000000..b67630dbf06 --- /dev/null +++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx @@ -0,0 +1,5 @@ +import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; + +export default function ThreadFilesIndexRoute() { + return ; +} diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index a3587d643ec..d98a8573e6c 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -40,7 +40,7 @@ export function AppTextInput({ - + T3 Code {stageLabel} @@ -38,7 +35,7 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab {!compact ? ( - + Mobile control surface for your live coding environments ) : null} diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbarTrigger.tsx index 7cb93454f88..e054a13f697 100644 --- a/apps/mobile/src/components/ComposerToolbarTrigger.tsx +++ b/apps/mobile/src/components/ComposerToolbarTrigger.tsx @@ -223,7 +223,7 @@ export function ComposerToolbarButton(props: { {props.label ? ( - - {props.actionLabel} - + {props.actionLabel} ) : null} diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 3fb8ba5d917..d47f924b398 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - + {props.message} diff --git a/apps/mobile/src/components/LoadingStrip.tsx b/apps/mobile/src/components/LoadingStrip.tsx new file mode 100644 index 00000000000..9c16e1c68e7 --- /dev/null +++ b/apps/mobile/src/components/LoadingStrip.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +const INDICATOR_WIDTH_FRACTION = 0.3; +const MIN_INDICATOR_WIDTH = 48; + +function LoadingStripFrame(props: { + readonly children: React.ReactNode; + readonly onLayout?: (width: number) => void; +}) { + return ( + { + props.onLayout?.(event.nativeEvent.layout.width); + } + : undefined + } + > + {props.children} + + ); +} + +function IndeterminateLoadingStrip() { + const [containerWidth, setContainerWidth] = useState(0); + const travelProgress = useSharedValue(0); + const indicatorWidth = Math.max(MIN_INDICATOR_WIDTH, containerWidth * INDICATOR_WIDTH_FRACTION); + + useEffect(() => { + travelProgress.value = 0; + travelProgress.value = withRepeat( + withTiming(1, { + duration: 1100, + easing: Easing.inOut(Easing.quad), + }), + -1, + false, + ); + + return () => { + cancelAnimation(travelProgress); + }; + }, [travelProgress]); + + const indicatorStyle = useAnimatedStyle( + () => ({ + transform: [ + { + translateX: (containerWidth + indicatorWidth) * travelProgress.value - indicatorWidth, + }, + ], + width: indicatorWidth, + }), + [containerWidth, indicatorWidth], + ); + + return ( + + + + ); +} + +export function LoadingStrip(props: { readonly progress?: number }) { + if (props.progress === undefined) { + return ; + } + + const clampedProgress = Math.min(1, Math.max(0, props.progress)); + + return ( + + + + ); +} diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index a3f377ce0ab..ba306c5a9fe 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,70 +1,86 @@ import { SymbolView } from "expo-symbols"; import { useState } from "react"; import { Image, View } from "react-native"; -import { resolveRemoteHttpUrl } from "../lib/remoteUrl"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useThemeColor } from "../lib/useThemeColor"; +import { useAssetUrl } from "../state/assets"; /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ const loadedFaviconUrls = new Set(); /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { + readonly environmentId: EnvironmentId; readonly size?: number; readonly projectTitle: string; - readonly httpBaseUrl?: string | null; readonly workspaceRoot?: string | null; - readonly bearerToken?: string | null; }) { const size = props.size ?? 42; - const iconMuted = useThemeColor("--color-icon-subtle"); + const faviconUrl = useAssetUrl( + props.environmentId, + props.workspaceRoot === null || props.workspaceRoot === undefined + ? null + : { _tag: "project-favicon", cwd: props.workspaceRoot }, + ); + + return ( + + ); +} - const faviconUrl = - props.httpBaseUrl && props.workspaceRoot - ? resolveRemoteHttpUrl({ - httpBaseUrl: props.httpBaseUrl, - pathname: "/api/project-favicon", - searchParams: { cwd: props.workspaceRoot }, - }) - : null; +function ProjectFaviconImage(props: { + readonly faviconUrl: string | null; + readonly projectTitle: string; + readonly size: number; +}) { + const iconMuted = useThemeColor("--color-icon-subtle"); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading", + props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", ); - const showImage = faviconUrl && status === "loaded"; + const showImage = props.faviconUrl !== null && status === "loaded"; return ( {/* Folder icon fallback (matches web's FolderIcon) */} {!showImage ? ( - + ) : null} {/* Favicon image (hidden until loaded) */} - {faviconUrl ? ( + {props.faviconUrl ? ( { - if (faviconUrl) loadedFaviconUrls.add(faviconUrl); + if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); setStatus("loaded"); }} onError={() => setStatus("error")} diff --git a/apps/mobile/src/components/StatusPill.tsx b/apps/mobile/src/components/StatusPill.tsx index 34e6f74b609..03985463aa8 100644 --- a/apps/mobile/src/components/StatusPill.tsx +++ b/apps/mobile/src/components/StatusPill.tsx @@ -26,7 +26,7 @@ export function StatusPill( diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts new file mode 100644 index 00000000000..b5bda400670 --- /dev/null +++ b/apps/mobile/src/connection/catalog-store.ts @@ -0,0 +1,122 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, + EMPTY_CONNECTION_CATALOG_DOCUMENT, +} from "@t3tools/client-runtime/platform"; +import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { migrateLegacyConnectionCatalog } from "./migration"; + +export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; +export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => catalogError("decode", cause), + }); + return yield* Effect.fromResult( + Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), + ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); +}); + +const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( + catalog: ConnectionCatalogDocumentType, +) { + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), + ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); + return JSON.stringify(encoded); +}); + +interface CatalogStore { + readonly read: Effect.Effect; + readonly update: ( + transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, + ) => Effect.Effect; +} + +export interface SecureCatalogStorage { + readonly getItem: (key: string) => Effect.Effect; + readonly setItem: (key: string, value: string) => Effect.Effect; + readonly deleteItem: (key: string) => Effect.Effect; +} + +export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( + storage: SecureCatalogStorage, +) { + const state = yield* Ref.make>(Option.none()); + const lock = yield* Semaphore.make(1); + + const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { + const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY); + const catalog = + legacyRaw === null || legacyRaw.trim() === "" + ? EMPTY_CONNECTION_CATALOG_DOCUMENT + : yield* migrateLegacyConnectionCatalog(legacyRaw).pipe( + Effect.mapError((cause) => catalogError("migrate", cause)), + Effect.catch((error) => + Effect.logWarning("Discarding corrupt legacy mobile connections", error).pipe( + Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), + ), + ), + ); + if (legacyRaw !== null && legacyRaw.trim() !== "") { + const encoded = yield* encodeCatalog(catalog); + yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); + } + return catalog; + }); + + const loadUnlocked = Effect.fn("mobile.connectionStorage.loadCatalog")(function* () { + const cached = yield* Ref.get(state); + if (Option.isSome(cached)) { + return cached.value; + } + const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); + let catalog: ConnectionCatalogDocumentType; + if (raw !== null && raw.trim() !== "") { + catalog = yield* decodeCatalog(raw).pipe( + Effect.catch((error) => + Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( + Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), + Effect.andThen(loadLegacyCatalog()), + ), + ), + ); + } else { + catalog = yield* loadLegacyCatalog(); + } + yield* Ref.set(state, Option.some(catalog)); + return catalog; + }); + + const read = lock.withPermits(1)(loadUnlocked()); + const update: CatalogStore["update"] = Effect.fn("mobile.connectionStorage.updateCatalog")( + function* (transform) { + yield* lock.withPermits(1)( + Effect.gen(function* () { + const next = transform(yield* loadUnlocked()); + const encoded = yield* encodeCatalog(next); + yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* Ref.set(state, Option.some(next)); + }), + ); + }, + ); + + return { read, update } satisfies CatalogStore; +}); diff --git a/apps/mobile/src/connection/catalog.ts b/apps/mobile/src/connection/catalog.ts new file mode 100644 index 00000000000..971fa891106 --- /dev/null +++ b/apps/mobile/src/connection/catalog.ts @@ -0,0 +1,5 @@ +import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections"; + +import { connectionAtomRuntime } from "./runtime"; + +export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/connection/migration.test.ts b/apps/mobile/src/connection/migration.test.ts new file mode 100644 index 00000000000..5cb17bd5bf7 --- /dev/null +++ b/apps/mobile/src/connection/migration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { migrateLegacyConnectionCatalog } from "./migration"; + +describe("migrateLegacyConnectionCatalog", () => { + it.effect("migrates bearer and relay-managed connections into the new catalog", () => + Effect.gen(function* () { + const bearerEnvironmentId = EnvironmentId.make("bearer-environment"); + const relayEnvironmentId = EnvironmentId.make("relay-environment"); + const catalog = yield* migrateLegacyConnectionCatalog( + JSON.stringify({ + connections: [ + { + environmentId: bearerEnvironmentId, + environmentLabel: "Local Mac", + pairingUrl: "https://local.example.test/pair", + displayUrl: "https://local.example.test", + httpBaseUrl: "https://local.example.test", + wsBaseUrl: "wss://local.example.test", + bearerToken: "bearer-token", + authenticationMethod: "bearer", + }, + { + environmentId: relayEnvironmentId, + environmentLabel: "Cloud Mac", + pairingUrl: "https://relay.example.test", + displayUrl: "https://relay.example.test", + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test", + bearerToken: null, + authenticationMethod: "dpop", + relayManaged: true, + }, + ], + }), + ); + + expect(catalog.targets).toHaveLength(2); + expect( + catalog.targets.find((target) => target.environmentId === bearerEnvironmentId)?._tag, + ).toBe("BearerConnectionTarget"); + expect( + catalog.targets.find((target) => target.environmentId === relayEnvironmentId)?._tag, + ).toBe("RelayConnectionTarget"); + expect(catalog.profiles).toHaveLength(1); + expect(catalog.credentials).toHaveLength(1); + expect(catalog.credentials[0]?.credential).toMatchObject({ + _tag: "BearerConnectionCredential", + token: "bearer-token", + }); + }), + ); + + it.effect("drops invalid legacy bearer entries without credentials", () => + Effect.gen(function* () { + const catalog = yield* migrateLegacyConnectionCatalog( + JSON.stringify({ + connections: [ + { + environmentId: EnvironmentId.make("invalid-bearer"), + environmentLabel: "Invalid", + pairingUrl: "https://invalid.example.test/pair", + displayUrl: "https://invalid.example.test", + httpBaseUrl: "https://invalid.example.test", + wsBaseUrl: "wss://invalid.example.test", + bearerToken: null, + authenticationMethod: "bearer", + }, + ], + }), + ); + + expect(catalog.targets).toEqual([]); + }), + ); +}); diff --git a/apps/mobile/src/connection/migration.ts b/apps/mobile/src/connection/migration.ts new file mode 100644 index 00000000000..6f324c9ff15 --- /dev/null +++ b/apps/mobile/src/connection/migration.ts @@ -0,0 +1,110 @@ +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + RelayConnectionRegistration, + RelayConnectionTarget, + BearerConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { + type ConnectionCatalogDocument, + EMPTY_CONNECTION_CATALOG_DOCUMENT, + registerConnectionInCatalog, +} from "@t3tools/client-runtime/platform"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const LegacySavedRemoteConnection = Schema.Struct({ + environmentId: EnvironmentId, + environmentLabel: Schema.String, + pairingUrl: Schema.String, + displayUrl: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + bearerToken: Schema.NullOr(Schema.String), + authenticationMethod: Schema.optionalKey(Schema.Literals(["bearer", "dpop"])), + dpopAccessToken: Schema.optionalKey(Schema.String), + relayManaged: Schema.optionalKey(Schema.Literal(true)), +}); + +const LegacyConnectionDocument = Schema.Struct({ + connections: Schema.optionalKey(Schema.Array(LegacySavedRemoteConnection)), +}); +const decodeLegacyConnectionDocument = Schema.decodeUnknownEffect(LegacyConnectionDocument); + +export class LegacyConnectionMigrationError extends Schema.TaggedErrorClass()( + "LegacyConnectionMigrationError", + { + message: Schema.String, + }, +) {} + +function isRelayManaged(connection: typeof LegacySavedRemoteConnection.Type): boolean { + return connection.relayManaged === true || connection.authenticationMethod === "dpop"; +} + +function migrateConnection( + document: ConnectionCatalogDocument, + connection: typeof LegacySavedRemoteConnection.Type, +): ConnectionCatalogDocument { + if (isRelayManaged(connection)) { + return registerConnectionInCatalog( + document, + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + }), + }), + ); + } + + if (connection.bearerToken === null || connection.bearerToken.trim() === "") { + return document; + } + + const connectionId = `bearer:${connection.environmentId}`; + return registerConnectionInCatalog( + document, + new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: connection.environmentId, + label: connection.environmentLabel, + httpBaseUrl: connection.httpBaseUrl, + wsBaseUrl: connection.wsBaseUrl, + }), + credential: new BearerConnectionCredential({ + token: connection.bearerToken, + }), + }), + ); +} + +export const migrateLegacyConnectionCatalog = Effect.fn( + "mobile.connectionMigration.migrateCatalog", +)(function* (raw: string) { + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => + new LegacyConnectionMigrationError({ + message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, + }), + }); + const legacy = yield* decodeLegacyConnectionDocument(parsed).pipe( + Effect.mapError( + (cause) => + new LegacyConnectionMigrationError({ + message: `Could not decode the legacy mobile connection catalog: ${String(cause)}`, + }), + ), + ); + + return (legacy.connections ?? []).reduce(migrateConnection, EMPTY_CONNECTION_CATALOG_DOCUMENT); +}); diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts new file mode 100644 index 00000000000..60a660cb4b8 --- /dev/null +++ b/apps/mobile/src/connection/onboarding.ts @@ -0,0 +1,35 @@ +import { ConnectionOnboarding } from "@t3tools/client-runtime/connection"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { connectionAtomRuntime } from "./runtime"; + +const onboardingScheduler = createAtomCommandScheduler(); + +export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { + label: "mobile:connection:connect-pairing-url", + scheduler: onboardingScheduler, + concurrency: { mode: "singleFlight", key: (pairingUrl: string) => pairingUrl }, + execute: (pairingUrl: string) => + ConnectionOnboarding.pipe( + Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })), + ), +}); + +export const updateBearerConnection = createRuntimeCommand(connectionAtomRuntime, { + label: "mobile:connection:update-bearer", + scheduler: onboardingScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly environmentId: EnvironmentId }) => input.environmentId, + }, + execute: (input: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + }) => ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.updateBearer(input))), +}); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts new file mode 100644 index 00000000000..769632a8fcb --- /dev/null +++ b/apps/mobile/src/connection/platform.ts @@ -0,0 +1,211 @@ +import { + ClientPresentation, + CloudSession, + EnvironmentOwnedDataCleanup, + PlatformConnectionSource, + PrimaryEnvironmentAuth, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "@t3tools/client-runtime/platform"; +import { + ConnectionBlockedError, + ConnectionTransientError, + Connectivity, + Wakeups, +} from "@t3tools/client-runtime/connection"; +import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as Network from "expo-network"; +import { AppState } from "react-native"; + +import { authClientMetadata } from "../lib/authClientMetadata"; +import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; +import { appAtomRegistry } from "../state/atom-registry"; +import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; +import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; +import { connectionStorageLayer } from "./storage"; + +function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { + if (state.isConnected === false || state.isInternetReachable === false) { + return "offline"; + } + if (state.isConnected === true) { + return "online"; + } + return "unknown"; +} + +const connectivityLayer = Connectivity.layer({ + status: Effect.tryPromise({ + try: () => Network.getNetworkStateAsync(), + catch: () => undefined, + }).pipe( + Effect.match({ + onFailure: () => "unknown" as const, + onSuccess: networkStatus, + }), + ), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => + Network.addNetworkStateListener((state) => { + Queue.offerUnsafe(queue, networkStatus(state)); + }), + ), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), +}); + +const wakeupsLayer = Wakeups.layer({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => + Effect.acquireRelease( + Effect.sync(() => + AppState.addEventListener("change", (state) => { + if (state === "active") { + Queue.offerUnsafe(queue, "application-active"); + } + }), + ), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), + ), + ), +}); + +const capabilitiesLayer = Layer.succeedContext( + Context.make( + CloudSession, + CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "Sign in to T3 Cloud to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + detail: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The T3 Cloud session is unavailable.", + }); + } + return token; + }), + }), + ).pipe( + Context.add( + PrimaryEnvironmentAuth, + PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), + ), + Context.add( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ + deviceId: Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not load the mobile device identity: ${String(cause)}`, + }), + }).pipe(Effect.map(Option.some)), + }), + ), + Context.add( + ClientPresentation, + ClientPresentation.of({ + metadata: authClientMetadata(), + scopes: AuthStandardClientScopes, + }), + ), + Context.add( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), + ), +); + +const platformConnectionSourceLayer = Layer.succeed( + PlatformConnectionSource, + PlatformConnectionSource.of({ + registrations: Stream.empty, + }), +); + +const environmentOwnedDataCleanupLayer = Layer.succeed( + EnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Effect.all( + [ + Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), + Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), + ], + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not clear mobile environment-owned data.", { + environmentId, + cause, + }), + ), + ), + }), +); + +type ConnectionPlatformLayerSource = + | typeof connectionStorageLayer + | typeof connectivityLayer + | typeof wakeupsLayer + | typeof capabilitiesLayer + | typeof platformConnectionSourceLayer + | typeof environmentOwnedDataCleanupLayer; + +export const connectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error, + Layer.Services +> = Layer.mergeAll( + connectionStorageLayer, + connectivityLayer, + wakeupsLayer, + capabilitiesLayer, + platformConnectionSourceLayer, + environmentOwnedDataCleanupLayer, +); diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts new file mode 100644 index 00000000000..3698a0a5fc7 --- /dev/null +++ b/apps/mobile/src/connection/runtime.ts @@ -0,0 +1,24 @@ +import { Connection } from "@t3tools/client-runtime/connection"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import { runtimeContextLayer } from "../lib/runtime"; +import { connectionPlatformLayer } from "./platform"; + +const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( + Layer.provide(runtimeContextLayer), +); + +type ConnectionLayerSource = + | typeof Connection.layer + | typeof runtimeContextLayer + | typeof connectionPlatformLayer; + +const connectionLayer = Connection.layer.pipe( + Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +); + +export const connectionAtomRuntime: Atom.AtomRuntime< + Layer.Success, + Layer.Error +> = Atom.runtime(connectionLayer); diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts new file mode 100644 index 00000000000..031c152e659 --- /dev/null +++ b/apps/mobile/src/connection/storage.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + CONNECTION_CATALOG_KEY, + LEGACY_CONNECTIONS_KEY, + makeCatalogStore, + type SecureCatalogStorage, +} from "./catalog-store"; + +function makeStorage(initial: Readonly>) { + const values = new Map(Object.entries(initial)); + const deleted: Array = []; + const storage: SecureCatalogStorage = { + getItem: (key) => Effect.sync(() => values.get(key) ?? null), + setItem: (key, value) => + Effect.sync(() => { + values.set(key, value); + }), + deleteItem: (key) => + Effect.sync(() => { + deleted.push(key); + values.delete(key); + }), + }; + return { deleted, storage, values }; +} + +describe("mobile connection catalog storage", () => { + it.effect("recovers from a corrupt current catalog", () => + Effect.gen(function* () { + const memory = makeStorage({ + [CONNECTION_CATALOG_KEY]: "{not-json", + }); + const catalog = yield* makeCatalogStore(memory.storage); + + expect((yield* catalog.read).targets).toEqual([]); + expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); + }), + ); + + it.effect("replaces and removes a corrupt legacy catalog", () => + Effect.gen(function* () { + const memory = makeStorage({ + [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), + }); + const catalog = yield* makeCatalogStore(memory.storage); + + expect((yield* catalog.read).targets).toEqual([]); + expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); + expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); + }), + ); + + it.effect("falls back to valid legacy data when the current catalog is corrupt", () => + Effect.gen(function* () { + const memory = makeStorage({ + [CONNECTION_CATALOG_KEY]: "{not-json", + [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ + connections: [ + { + environmentId: "legacy-environment", + environmentLabel: "Legacy", + pairingUrl: "https://legacy.example.test/pair", + displayUrl: "https://legacy.example.test", + httpBaseUrl: "https://legacy.example.test", + wsBaseUrl: "wss://legacy.example.test", + bearerToken: "legacy-token", + authenticationMethod: "bearer", + }, + ], + }), + }); + const catalog = yield* makeCatalogStore(memory.storage); + + expect((yield* catalog.read).targets).toHaveLength(1); + expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); + + yield* catalog.update((document) => document); + expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); + expect(memory.values.has(LEGACY_CONNECTIONS_KEY)).toBe(false); + }), + ); +}); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts new file mode 100644 index 00000000000..276ea3c5c08 --- /dev/null +++ b/apps/mobile/src/connection/storage.ts @@ -0,0 +1,432 @@ +import { + ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EnvironmentCacheStore, + registerConnectionInCatalog, + removeConnectionFromCatalog, + removeCatalogValue, + replaceCatalogValue, +} from "@t3tools/client-runtime/platform"; +import { TokenStore } from "@t3tools/client-runtime/authorization"; +import { + ConnectionTransientError, + CredentialStore, + ProfileStore, +} from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + OrchestrationThread, + OrchestrationShellSnapshot, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SecureStore from "expo-secure-store"; + +import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; + +const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; +const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; + +const StoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, +}); + +const StoredThreadSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + threadId: ThreadId, + thread: OrchestrationThread, +}); + +const LegacyStoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(1), + environmentId: EnvironmentId, + snapshotReceivedAt: Schema.String, + snapshot: OrchestrationShellSnapshot, +}); + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +function shellPersistenceError( + operation: + | "load-shell" + | "save-shell" + | "load-thread" + | "save-thread" + | "remove-thread" + | "clear-environment", + cause: unknown, +) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +function threadSnapshotFileName(threadId: ThreadId): string { + return `${encodeURIComponent(threadId)}.json`; +} + +const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( + function* ( + environmentId: EnvironmentId, + operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment", + ) { + return yield* Effect.tryPromise({ + try: async () => { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory( + Paths.document, + THREAD_SNAPSHOT_CACHE_DIRECTORY, + encodeURIComponent(environmentId), + ); + if (operation !== "clear-environment") { + directory.create({ idempotent: true, intermediates: true }); + } + return directory; + }, + catch: (cause) => shellPersistenceError(operation, cause), + }); + }, +); + +const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* ( + environmentId: EnvironmentId, + threadId: ThreadId, + operation: "load-thread" | "save-thread" | "remove-thread", +) { + const { File } = yield* Effect.promise(() => import("expo-file-system")); + return new File( + yield* threadSnapshotDirectory(environmentId, operation), + threadSnapshotFileName(threadId), + ); +}); + +function targetPersistenceError( + operation: "list-targets" | "register-connection" | "remove-connection", + error: ConnectionTransientError, +) { + return new ConnectionPersistenceError({ + operation, + message: error.message, + }); +} + +const secureCatalogStorage: SecureCatalogStorage = { + getItem: (key) => + Effect.tryPromise({ + try: () => SecureStore.getItemAsync(key), + catch: (cause) => catalogError("load", cause), + }), + setItem: (key, value) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(key, value), + catch: (cause) => catalogError("save", cause), + }), + deleteItem: (key) => + Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(key), + catch: (cause) => catalogError("delete", cause), + }), +}; + +function shellSnapshotFileName(environmentId: EnvironmentId): string { + return `${encodeURIComponent(environmentId)}.json`; +} + +const shellSnapshotFileInDirectory = Effect.fn( + "mobile.connectionStorage.shellSnapshotFileInDirectory", +)(function* ( + environmentId: EnvironmentId, + operation: "load-shell" | "save-shell" | "clear-environment", + directoryName: string, +) { + return yield* Effect.tryPromise({ + try: async () => { + const { Directory, File, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, directoryName); + directory.create({ idempotent: true, intermediates: true }); + return new File(directory, shellSnapshotFileName(environmentId)); + }, + catch: (cause) => shellPersistenceError(operation, cause), + }); +}); + +const shellSnapshotFile = ( + environmentId: EnvironmentId, + operation: "load-shell" | "save-shell" | "clear-environment", +) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); + +const legacyShellSnapshotFile = ( + environmentId: EnvironmentId, + operation: "load-shell" | "clear-environment", +) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); + +export const connectionStorageLayer = Layer.effectContext( + Effect.gen(function* () { + const catalog = yield* makeCatalogStore(secureCatalogStorage); + + const targetStore = ConnectionTargetStore.of({ + list: catalog.read.pipe( + Effect.map((document) => document.targets), + Effect.mapError((error) => targetPersistenceError("list-targets", error)), + ), + }); + const registrationStore = ConnectionRegistrationStore.of({ + register: (registration) => + catalog + .update((document) => registerConnectionInCatalog(document, registration)) + .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), + remove: (target) => + catalog + .update((document) => removeConnectionFromCatalog(document, target)) + .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), + }); + const profileStore = ProfileStore.make({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.profiles.find((candidate) => candidate.connectionId === connectionId), + ), + ), + ), + put: (profile) => + catalog.update((document) => ({ + ...document, + profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + profiles: removeCatalogValue( + document.profiles, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const credentialStore = CredentialStore.make({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.credentials.find((entry) => entry.connectionId === connectionId)?.credential, + ), + ), + ), + put: (connectionId, credential) => + catalog.update((document) => ({ + ...document, + credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, { + connectionId, + credential, + }), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + credentials: removeCatalogValue( + document.credentials, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const remoteTokenStore = TokenStore.make({ + get: (environmentId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.remoteDpopTokens.find((token) => token.environmentId === environmentId), + ), + ), + ), + put: (token) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: replaceCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + token, + ), + })), + remove: (environmentId) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + environmentId, + ), + })), + }); + const cacheStore = EnvironmentCacheStore.of({ + loadShell: (environmentId) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "load-shell"); + if (file.exists) { + const raw = yield* Effect.tryPromise({ + try: () => file.text(), + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const stored = yield* Effect.fromResult( + Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + return stored.environmentId === environmentId + ? Option.some(stored.snapshot) + : Option.none(); + } + + const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); + if (!legacyFile.exists) { + return Option.none(); + } + const legacyRaw = yield* Effect.tryPromise({ + try: () => legacyFile.text(), + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const legacyParsed = yield* Effect.try({ + try: () => JSON.parse(legacyRaw) as unknown, + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const legacyStored = yield* Effect.fromResult( + Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + return legacyStored.environmentId === environmentId + ? Option.some(legacyStored.snapshot) + : Option.none(); + }), + saveShell: (environmentId, snapshot) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "save-shell"); + const stored = { + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + } as const; + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(StoredShellSnapshot)(stored), + ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); + yield* Effect.try({ + try: () => { + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + }, + catch: (cause) => shellPersistenceError("save-shell", cause), + }); + }), + loadThread: (environmentId, threadId) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); + if (!file.exists) { + return Option.none(); + } + const raw = yield* Effect.tryPromise({ + try: () => file.text(), + catch: (cause) => shellPersistenceError("load-thread", cause), + }); + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => shellPersistenceError("load-thread", cause), + }); + const stored = yield* Effect.fromResult( + Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); + return stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.thread) + : Option.none(); + }), + saveThread: (environmentId, thread) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread"); + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(StoredThreadSnapshot)({ + schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + threadId: thread.id, + thread, + }), + ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); + yield* Effect.try({ + try: () => { + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + }, + catch: (cause) => shellPersistenceError("save-thread", cause), + }); + }), + removeThread: (environmentId, threadId) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); + if (file.exists) { + file.delete(); + } + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : shellPersistenceError("remove-thread", cause), + ), + ), + clear: (environmentId) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "clear-environment"); + if (file.exists) { + yield* Effect.try({ + try: () => file.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); + if (legacyFile.exists) { + yield* Effect.try({ + try: () => legacyFile.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + const threadDirectory = yield* threadSnapshotDirectory( + environmentId, + "clear-environment", + ); + if (threadDirectory.exists) { + yield* Effect.try({ + try: () => threadDirectory.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + }), + }); + + return Context.make(ConnectionTargetStore, targetStore).pipe( + Context.add(ConnectionRegistrationStore, registrationStore), + Context.add(ProfileStore.ConnectionProfileStore, profileStore), + Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), + Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(EnvironmentCacheStore, cacheStore), + ); + }), +); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index f06868ed7d9..5de14ea76fc 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -2,7 +2,8 @@ import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import type { EnvironmentId } from "@t3tools/contracts"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; @@ -33,90 +34,85 @@ const connection: SavedRemoteConnection = { bearerToken: "local-bearer", }; -const runWithHttpClient = ( - effect: Effect.Effect, -): Promise => - Effect.runPromise( - effect.pipe( - Effect.provideService(ManagedRelayClient, null as never), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), - ), - ), - ); +const testLayer = Layer.mergeAll( + Layer.succeed(ManagedRelay.ManagedRelayClient, null as never), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HTTP request")), + ), +); describe("liveActivityPreferences", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("pushes disabled Live Activity preferences to relay registrations", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + it.effect("pushes disabled Live Activity preferences to relay registrations", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: "clerk-token", connections: [connection], - }), - ); - - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); - }); + }); - it("pushes enabled Live Activity preferences to relay registrations", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("pushes enabled Live Activity preferences to relay registrations", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: true, clerkToken: "clerk-token", connections: [connection], - }), - ); - - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); - }); + }); - it("keeps local preferences refreshable when signed out", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps local preferences refreshable when signed out", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: null, connections: [connection], - }), - ); + }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); - }); + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer)), + ); - it("does not try to re-link managed relay connections without bearer credentials", async () => { + it.effect("does not try to re-link managed relay connections without bearer credentials", () => { const managedConnection: SavedRemoteConnection = { ...connection, bearerToken: null, }; - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + return Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: true, clerkToken: "clerk-token", connections: [connection, managedConnection], - }), - ); - - expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); + }); + + expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)); }); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 7bf29483f1d..932376e8bce 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,21 +1,34 @@ import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; import { savePreferencesPatch } from "../../lib/storage"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; +export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass()( + "LiveActivityPreferenceSaveError", + { + enabled: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`; + } +} + export function setLiveActivityUpdatesEnabled(input: { readonly enabled: boolean; readonly clerkToken: string | null; readonly connections: ReadonlyArray; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { yield* Effect.tryPromise({ try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), - catch: (error) => error, + catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), }); yield* refreshAgentAwarenessRegistration(); diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 6d7c247dfad..2dd3ca03de2 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { NotificationResponse } from "expo-notifications"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; import { extractAgentNotificationDeepLink, @@ -18,6 +21,76 @@ function responseWithData(data: Record, identifier = "notificat }; } +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("consumeLastAgentNotificationResponse", () => { + it("reports which initial-response operation failed", async () => { + const cause = new Error("notification lookup unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.reject(cause), + clearLastResponse: () => Promise.resolve(), + handleResponse: vi.fn(), + }); + + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "read", + }), + ); + }); + + it("routes a response before reporting a clear failure", async () => { + const cause = new Error("notification clear unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({}, "notification-clear") as NotificationResponse; + const handleResponse = vi.fn(); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse: () => Promise.reject(cause), + handleResponse, + }); + + expect(handleResponse).toHaveBeenCalledWith(response); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "clear", + notificationId: "notification-clear", + }), + ); + }); + + it("reports routing failures before clearing the response", async () => { + const cause = new Error("notification routing unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({}, "notification-route") as NotificationResponse; + const clearLastResponse = vi.fn(() => Promise.resolve()); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: () => { + throw cause; + }, + }); + + expect(clearLastResponse).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "route", + notificationId: "notification-route", + }), + ); + }); +}); + describe("extractAgentNotificationDeepLink", () => { it("uses explicit deep links from APNs payload data", () => { expect( diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index a7027623653..18bb93d723e 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -3,6 +3,7 @@ import * as Notifications from "expo-notifications"; import { useRouter } from "expo-router"; import { routeAgentNotificationResponseOnce } from "./notificationPayload"; +import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; export function useAgentNotificationNavigation(): void { const router = useRouter(); @@ -18,15 +19,11 @@ export function useAgentNotificationNavigation(): void { }; const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); - void Notifications.getLastNotificationResponseAsync() - .then((response) => { - if (response) { - handleResponse(response); - return Notifications.clearLastNotificationResponseAsync(); - } - return undefined; - }) - .catch(() => undefined); + void consumeLastAgentNotificationResponse({ + getLastResponse: () => Notifications.getLastNotificationResponseAsync(), + clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(), + handleResponse, + }); return () => { subscription.remove(); diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index ce8dfddf3d2..dc275774a50 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -1,5 +1,6 @@ import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Platform } from "react-native"; export type NotificationPermissionResult = @@ -7,9 +8,31 @@ export type NotificationPermissionResult = | { readonly type: "granted" } | { readonly type: "denied"; readonly canAskAgain: boolean }; +export class NotificationPermissionReadError extends Schema.TaggedErrorClass()( + "NotificationPermissionReadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to read notification permissions on iOS."; + } +} + +export class NotificationPermissionRequestError extends Schema.TaggedErrorClass()( + "NotificationPermissionRequestError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to request notification permissions on iOS."; + } +} + export const requestAgentNotificationPermission: Effect.Effect< NotificationPermissionResult, - unknown + NotificationPermissionReadError | NotificationPermissionRequestError > = Effect.gen(function* () { if (Platform.OS !== "ios") { return { type: "unsupported" }; @@ -17,7 +40,7 @@ export const requestAgentNotificationPermission: Effect.Effect< const existing = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), - catch: (error) => error, + catch: (cause) => new NotificationPermissionReadError({ cause }), }); if (existing.granted) { return { type: "granted" }; @@ -36,7 +59,7 @@ export const requestAgentNotificationPermission: Effect.Effect< allowSound: true, }, }), - catch: (error) => error, + catch: (cause) => new NotificationPermissionRequestError({ cause }), }); return requested.granted ? { type: "granted" } diff --git a/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts b/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts new file mode 100644 index 00000000000..be6bfa820fa --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts @@ -0,0 +1,58 @@ +import type { NotificationResponse } from "expo-notifications"; +import * as Schema from "effect/Schema"; + +export class NotificationNavigationError extends Schema.TaggedErrorClass()( + "NotificationNavigationError", + { + operation: Schema.Literals(["read", "route", "clear"]), + notificationId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} the last notification response.`; + } +} + +export async function consumeLastAgentNotificationResponse(input: { + readonly getLastResponse: () => Promise; + readonly clearLastResponse: () => Promise; + readonly handleResponse: (response: NotificationResponse) => void; +}): Promise { + let response: NotificationResponse | null; + try { + response = await input.getLastResponse(); + } catch (cause) { + console.error(new NotificationNavigationError({ operation: "read", cause })); + return; + } + + if (!response) { + return; + } + + try { + input.handleResponse(response); + } catch (cause) { + console.error( + new NotificationNavigationError({ + operation: "route", + notificationId: response.notification.request.identifier, + cause, + }), + ); + return; + } + + try { + await input.clearLastResponse(); + } catch (cause) { + console.error( + new NotificationNavigationError({ + operation: "clear", + notificationId: response.notification.request.identifier, + cause, + }), + ); + } +} diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 44ef38df0ef..a4e6fc3d6db 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -1,6 +1,6 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; -import type { MobilePreferences } from "../../lib/storage"; +import type { Preferences } from "../../lib/storage"; export function makeRelayDeviceRegistrationRequest(input: { readonly deviceId: string; @@ -10,7 +10,7 @@ export function makeRelayDeviceRegistrationRequest(input: { readonly pushToken?: string; readonly pushToStartToken?: string; readonly notificationsEnabled: boolean; - readonly preferences: MobilePreferences; + readonly preferences: Preferences; }): RelayDeviceRegistrationRequest { const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; return { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 346680df8c0..7f97d7c718c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -6,17 +6,19 @@ import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import Constants from "expo-constants"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import { FetchHttpClient } from "effect/unstable/http"; -import type { ManagedRelayClient } from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { mobileCryptoLayer } from "../cloud/dpop"; -import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { cryptoLayer } from "../cloud/dpop"; +import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { + AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, @@ -33,6 +35,12 @@ const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ getInstances: vi.fn(() => []), })); +const backgroundRuntime = vi.hoisted(() => ({ + pending: [] as Array<{ + readonly operation: unknown; + readonly resolve: (exit: Exit.Exit) => void; + }>, +})); vi.mock("expo-constants", () => ({ default: { @@ -95,17 +103,11 @@ vi.mock("react-native", () => ({ })); vi.mock("../../lib/runtime", () => ({ - mobileRuntime: { - runPromise: (operation: Effect.Effect) => - Effect.runPromise( - operation.pipe( - Effect.provide( - mobileManagedRelayClientLayer("https://relay.example.test").pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), - ), - ), - ), - ), + runtime: { + runPromiseExit: (operation: unknown) => + new Promise((resolve) => { + backgroundRuntime.pending.push({ operation, resolve }); + }), }, })); @@ -138,34 +140,40 @@ function savedConnection(): SavedRemoteConnection { }; } -const runRegistrationEffect = (effect: Effect.Effect): Promise => - Effect.runPromise( - effect.pipe( - Effect.provide( - mobileManagedRelayClientLayer("https://relay.example.test").pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), - ), - ), - ), - ); - -async function waitForFetchCalls( - fetchMock: ReturnType, - count: number, -): Promise { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (fetchMock.mock.calls.length >= count) { - return; +const relayTestLayer = managedRelayClientLayer("https://relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), +); + +const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")( + function* () { + let idlePasses = 0; + for (;;) { + yield* Effect.promise(() => Promise.resolve()); + const pending = backgroundRuntime.pending.shift(); + if (!pending) { + idlePasses++; + if (idlePasses >= 3) { + return; + } + continue; + } + idlePasses = 0; + const exit = yield* Effect.exit( + pending.operation as Effect.Effect, + ); + yield* Effect.sync(() => { + pending.resolve(exit); + }); } - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} + }, +); describe("makeRelayDeviceRegistrationRequest", () => { beforeEach(() => { vi.unstubAllGlobals(); vi.stubGlobal("__DEV__", false); secureStore.clear(); + backgroundRuntime.pending.length = 0; Constants.expoConfig!.extra = {}; __resetAgentAwarenessRemoteRegistrationForTest(); widgetMocks.getInstances.mockReset(); @@ -243,7 +251,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); }); - it("registers at most one listener while a Live Activity push token is pending", async () => { + it.effect("registers at most one listener while a Live Activity push token is pending", () => { registerAgentAwarenessConnection(savedConnection()); const addPushTokenListener = vi.fn(); const activity = { @@ -251,56 +259,86 @@ describe("makeRelayDeviceRegistrationRequest", () => { addPushTokenListener, }; - await expect( - runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), - ).resolves.toBe(false); - await expect( - runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), - ).resolves.toBe(false); + return Effect.gen(function* () { + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); - expect(activity.getPushToken).toHaveBeenCalledTimes(2); - expect(addPushTokenListener).toHaveBeenCalledTimes(1); + expect(activity.getPushToken).toHaveBeenCalledTimes(2); + expect(addPushTokenListener).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(relayTestLayer)); }); - it("reports Live Activity token registration as skipped when relay auth is unavailable", async () => { - registerAgentAwarenessConnection(savedConnection()); + it.effect("preserves Live Activity push-token lookup failures", () => { + const cause = new Error("native token lookup failed"); const activity = { - getPushToken: vi.fn(() => Promise.resolve("activity-token")), + getPushToken: vi.fn(() => Promise.reject(cause)), addPushTokenListener: vi.fn(), }; - await expect( - runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), - ).resolves.toBe(false); + return Effect.gen(function* () { + const error = yield* Effect.flip( + registerLiveActivityPushToken({ activity: activity as never }), + ); + + expect(error).toBeInstanceOf(AgentAwarenessOperationError); + expect(error).toMatchObject({ + _tag: "AgentAwarenessOperationError", + operation: "read-live-activity-push-token", + cause, + message: "Agent awareness operation read-live-activity-push-token failed.", + }); + }).pipe(Effect.provide(relayTestLayer)); }); - it("registers APNS-started Live Activities for relay updates without mutating them locally", async () => { - const activity = { - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - start: vi.fn(), - update: vi.fn(), - end: vi.fn(), - }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + it.effect( + "reports Live Activity token registration as skipped when relay auth is unavailable", + () => { + registerAgentAwarenessConnection(savedConnection()); + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; - await runRegistrationEffect(refreshActiveLiveActivityRemoteRegistration()); + return Effect.gen(function* () { + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); + }).pipe(Effect.provide(relayTestLayer)); + }, + ); - expect(activity.getPushToken).toHaveBeenCalled(); - expect(activity.start).not.toHaveBeenCalled(); - expect(activity.update).not.toHaveBeenCalled(); - expect(activity.end).not.toHaveBeenCalled(); - }); + it.effect( + "registers APNS-started Live Activities for relay updates without mutating them locally", + () => { + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + start: vi.fn(), + update: vi.fn(), + end: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); - it("refreshes APNs registration for connected environments after settings changes", async () => { + expect(activity.getPushToken).toHaveBeenCalled(); + expect(activity.start).not.toHaveBeenCalled(); + expect(activity.update).not.toHaveBeenCalled(); + expect(activity.end).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }, + ); + + it.effect("refreshes APNs registration for connected environments after settings changes", () => { registerAgentAwarenessConnection(savedConnection()); - await new Promise((resolve) => setTimeout(resolve, 0)); - vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); - await runRegistrationEffect(refreshAgentAwarenessRegistration()); + yield* refreshAgentAwarenessRegistration(); - expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(relayTestLayer)); }); it.effect("registers the APNs device when cloud auth becomes available", () => { @@ -330,7 +368,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); return Effect.gen(function* () { - yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); + yield* runBackgroundOperations(); expect(fetchMock).toHaveBeenCalledTimes(2); const [request, init] = fetchMock.mock.calls[1] as unknown as [ @@ -357,7 +395,65 @@ describe("makeRelayDeviceRegistrationRequest", () => { nowEpochSeconds: proofIat(dpop), }), ).toMatchObject({ ok: true }); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("coalesces simultaneous sign-in and environment connection registrations", () => { + const fetchMock = vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + } + : { ok: true }, + ), + ); }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + + vi.mocked(Notifications.getPermissionsAsync).mockClear(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + registerAgentAwarenessConnection(savedConnection()); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("continues queued device registration after a failed auth lookup", () => { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + + const tokenProvider = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("auth unavailable")) + .mockResolvedValue("clerk-token-user-a"); + setAgentAwarenessRelayTokenProvider(tokenProvider); + const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; + expect(tokenListener).toBeDefined(); + tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + + expect(backgroundRuntime.pending).toHaveLength(0); + expect(tokenProvider).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide(relayTestLayer)); }); it("only registers again when the authenticated identity changes", () => { @@ -367,7 +463,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true); }); - it("registers rotated APNs tokens without rereading the native token", async () => { + it.effect("registers rotated APNs tokens without rereading the native token", () => { const fetchMock = vi.fn((request: RequestInfo | URL) => { const url = request instanceof Request ? request.url : String(request); return Promise.resolve( @@ -398,9 +494,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(tokenListener).toBeDefined(); tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(relayTestLayer)); }); it.effect( @@ -432,13 +529,13 @@ describe("makeRelayDeviceRegistrationRequest", () => { registerAgentAwarenessConnection(savedConnection()); setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); return Effect.gen(function* () { - yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); + yield* runBackgroundOperations(); fetchMock.mockClear(); unregisterAgentAwarenessConnection(savedConnection().environmentId); expect(fetchMock).not.toHaveBeenCalled(); - }); + }).pipe(Effect.provide(relayTestLayer)); }, ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 3e49ec1e257..3281381e0e1 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -2,16 +2,23 @@ import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { type RelayDeviceRegistrationRequest, type RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { + isAtomCommandInterrupted, + settleAsyncResult, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { mobileRuntime } from "../../lib/runtime"; +import { runtime } from "../../lib/runtime"; import { loadAgentAwarenessDeviceId, loadOrCreateAgentAwarenessDeviceId, @@ -22,6 +29,33 @@ import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; + +const AgentAwarenessOperation = Schema.Literals([ + "read-notification-permissions", + "read-native-push-token", + "read-device-registration-relay-token", + "read-device-unregistration-relay-token", + "read-live-activity-registration-relay-token", + "load-device-registration-identifier", + "load-device-registration-preferences", + "load-device-unregistration-identifier", + "read-live-activity-push-token", + "load-live-activity-registration-identifier", + "list-active-live-activities", +]); + +export class AgentAwarenessOperationError extends Schema.TaggedErrorClass()( + "AgentAwarenessOperationError", + { + operation: AgentAwarenessOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Agent awareness operation ${this.operation} failed.`; + } +} + const environmentConnections = new Map(); const activityPushTokenListeners = new WeakSet>(); let pushToStartSubscription: { remove: () => void } | null = null; @@ -29,6 +63,20 @@ let pushTokenSubscription: { remove: () => void } | null = null; let activeLiveActivityRegistrationRetry: ReturnType | null = null; let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; +let deviceRegistrationGeneration = 0; +let activeDeviceRegistration: { + readonly input: DeviceRegistrationInput; + operation: Promise; +} | null = null; +let pendingDeviceRegistration: { + readonly input: DeviceRegistrationInput; + readonly context: string; +} | null = null; + +interface DeviceRegistrationInput { + readonly pushToStartToken?: string; + readonly observedPushToken?: string; +} export function normalizeAgentAwarenessRelayBaseUrl( value: string | null | undefined, @@ -68,6 +116,11 @@ export function setAgentAwarenessRelayTokenProvider( const isExistingIdentity = provider !== null && !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity); + if (!isExistingIdentity) { + deviceRegistrationGeneration++; + activeDeviceRegistration = null; + pendingDeviceRegistration = null; + } relayTokenProvider = provider; relayTokenProviderIdentity = provider ? (identity ?? null) : null; if (!provider) { @@ -90,7 +143,7 @@ export function setAgentAwarenessRelayTokenProvider( if (isExistingIdentity) { return; } - runRegistrationInBackground(registerDevice(), "device registration after cloud sign-in failed"); + enqueueDeviceRegistration({}, "device registration after cloud sign-in failed"); } function iosMajorVersion(): number { @@ -112,14 +165,22 @@ function nativePushTokenRegistration(observedPushToken?: string) { } const permissions = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-notification-permissions", + cause, + }), }); if (!permissions.granted) { return { notificationsEnabled: false, pushToken: null }; } const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-native-push-token", + cause, + }), }).pipe( Effect.tapError((error) => Effect.sync(() => { @@ -136,52 +197,80 @@ function nativePushTokenRegistration(observedPushToken?: string) { }); } -const relayToken = Effect.gen(function* () { - const provider = relayTokenProvider; - if (!provider) { - return null; - } - return yield* Effect.tryPromise({ - try: provider, - catch: (error) => error, +const relayToken = ( + operation: "read-device-registration-relay-token" | "read-live-activity-registration-relay-token", +) => + Effect.gen(function* () { + const provider = relayTokenProvider; + if (!provider) { + return null; + } + return yield* Effect.tryPromise({ + try: provider, + catch: (cause) => new AgentAwarenessOperationError({ operation, cause }), + }); }); -}); function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, -): Effect.Effect { + expectedGeneration: number, +): Effect.Effect { return Effect.gen(function* () { + if (expectedGeneration !== deviceRegistrationGeneration) { + logRegistrationDebug("device registration cancelled before relay request", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } if (!readRelayConfig()) return; - const token = yield* relayToken; + const token = yield* relayToken("read-device-registration-relay-token"); + if (expectedGeneration !== deviceRegistrationGeneration) { + logRegistrationDebug("device registration cancelled after auth lookup", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } if (!token) { logRegistrationDebug("relay device registration skipped; user is not signed in"); return; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; + logRegistrationDebug("relay device registration request started", { + expectedGeneration, + }); yield* client.registerDevice({ clerkToken: token, payload: body, }); + logRegistrationDebug("relay device registration request completed", { + expectedGeneration, + }); }); } function unregisterDeviceWithRelay(input: { readonly deviceId: string; readonly tokenProvider: () => Promise; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { if (!readRelayConfig()) return; const token = yield* Effect.tryPromise({ try: input.tokenProvider, - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-device-unregistration-relay-token", + cause, + }), }); if (!token) { logRegistrationDebug("relay device unregistration skipped; user is not signed in"); return; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; yield* client.unregisterDevice({ clerkToken: token, deviceId: input.deviceId, @@ -191,16 +280,16 @@ function unregisterDeviceWithRelay(input: { function registerLiveActivityWithRelay( body: RelayLiveActivityRegistrationRequest, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { if (!readRelayConfig()) return false; - const token = yield* relayToken; + const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { logRegistrationDebug("relay live activity registration skipped; user is not signed in"); return false; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; yield* client.registerLiveActivity({ clerkToken: token, payload: body, @@ -213,10 +302,11 @@ function logRegistrationError(context: string, error: unknown): void { if (!__DEV__) { return; } - console.warn( - `[agent-awareness] ${context}`, - error instanceof Error ? error.message : String(error), - ); + console.warn(`[agent-awareness] ${context}`, { + message: error instanceof Error ? error.message : String(error), + traceId: findErrorTraceId(error), + error, + }); } function logRegistrationDebug(context: string, details?: unknown): void { @@ -227,34 +317,133 @@ function logRegistrationDebug(context: string, details?: unknown): void { } function runRegistrationInBackground( - operation: Effect.Effect, + operation: Effect.Effect, context: string, ): void { - void mobileRuntime.runPromise(operation).catch((error: unknown) => { - logRegistrationError(context, error); + void (async () => { + const result = await settleAsyncResult(() => runtime.runPromiseExit(operation)); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + logRegistrationError(context, squashAtomCommandFailure(result)); + } + })(); +} + +function mergeDeviceRegistrationInput( + current: DeviceRegistrationInput, + next: DeviceRegistrationInput, +): DeviceRegistrationInput { + return { + ...((next.pushToStartToken ?? current.pushToStartToken) + ? { pushToStartToken: next.pushToStartToken ?? current.pushToStartToken } + : {}), + ...((next.observedPushToken ?? current.observedPushToken) + ? { observedPushToken: next.observedPushToken ?? current.observedPushToken } + : {}), + }; +} + +function registrationAddsInformation( + current: DeviceRegistrationInput, + next: DeviceRegistrationInput, +): boolean { + return ( + (next.pushToStartToken !== undefined && next.pushToStartToken !== current.pushToStartToken) || + (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken) + ); +} + +function startPendingDeviceRegistration(): void { + if (activeDeviceRegistration || !pendingDeviceRegistration) { + return; + } + + const next = pendingDeviceRegistration; + pendingDeviceRegistration = null; + const generation = deviceRegistrationGeneration; + logRegistrationDebug("device registration started", { + generation, + hasObservedPushToken: next.input.observedPushToken !== undefined, + hasPushToStartToken: next.input.pushToStartToken !== undefined, }); + const registration = { + input: next.input, + operation: Promise.resolve(), + }; + activeDeviceRegistration = registration; + registration.operation = (async () => { + const result = await settleAsyncResult(() => + runtime.runPromiseExit(registerDevice(next.input, generation)), + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + logRegistrationError(next.context, squashAtomCommandFailure(result)); + } + logRegistrationDebug("device registration finished", { generation }); + if (activeDeviceRegistration === registration) { + activeDeviceRegistration = null; + } + startPendingDeviceRegistration(); + })(); } -function registerDevice(input?: { - readonly pushToStartToken?: string; - readonly observedPushToken?: string; -}): Effect.Effect { +function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: string): void { + if ( + activeDeviceRegistration && + !registrationAddsInformation(activeDeviceRegistration.input, input) + ) { + logRegistrationDebug("device registration coalesced with active request", { + generation: deviceRegistrationGeneration, + }); + return; + } + + logRegistrationDebug("device registration enqueued", { + generation: deviceRegistrationGeneration, + hasActiveRegistration: activeDeviceRegistration !== null, + hasPendingRegistration: pendingDeviceRegistration !== null, + }); + pendingDeviceRegistration = pendingDeviceRegistration + ? { + input: mergeDeviceRegistrationInput(pendingDeviceRegistration.input, input), + context, + } + : { input, context }; + startPendingDeviceRegistration(); +} + +function registerDevice( + input: DeviceRegistrationInput = {}, + expectedGeneration = deviceRegistrationGeneration, +): Effect.Effect { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities()) { + logRegistrationDebug("device registration skipped; platform does not support it"); return; } + logRegistrationDebug("device registration loading local state", { expectedGeneration }); const [deviceId, preferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-identifier", + cause, + }), }), Effect.tryPromise({ try: () => loadPreferences(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-preferences", + cause, + }), }), ]); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); + logRegistrationDebug("device registration local state ready", { + expectedGeneration, + notificationsEnabled: pushTokenRegistration.notificationsEnabled, + }); yield* registerDeviceWithRelay( makeRelayDeviceRegistrationRequest({ deviceId, @@ -266,21 +455,19 @@ function registerDevice(input?: { notificationsEnabled: pushTokenRegistration.notificationsEnabled, preferences, }), + expectedGeneration, ); }); } function registerDeviceForCurrentUser( pushToStartToken?: string, -): Effect.Effect { +): Effect.Effect { return registerDevice(pushToStartToken ? { pushToStartToken } : undefined); } function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { - runRegistrationInBackground( - registerDeviceForCurrentUser(pushToStartToken), - "push-to-start token registration failed", - ); + enqueueDeviceRegistration({ pushToStartToken }, "push-to-start token registration failed"); } function ensurePushToStartListener(): void { @@ -303,8 +490,8 @@ function ensurePushTokenListener(): void { pushTokenSubscription = Notifications.addPushTokenListener((token) => { if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) { - runRegistrationInBackground( - registerDevice({ observedPushToken: token.data.trim() }), + enqueueDeviceRegistration( + { observedPushToken: token.data.trim() }, "native APNs token rotation registration failed", ); } @@ -319,7 +506,7 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti environmentConnections.set(connection.environmentId, connection); ensurePushToStartListener(); ensurePushTokenListener(); - runRegistrationInBackground(registerDevice(), "device registration failed"); + enqueueDeviceRegistration({}, "device registration failed"); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity registration after environment connection failed", @@ -349,7 +536,7 @@ export function unregisterAllAgentAwarenessConnections(): void { export function refreshAgentAwarenessRegistration(): Effect.Effect< void, never, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return registerDeviceForCurrentUser().pipe( Effect.catch((error) => @@ -372,15 +559,22 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { } relayTokenProvider = null; relayTokenProviderIdentity = null; + deviceRegistrationGeneration++; + activeDeviceRegistration = null; + pendingDeviceRegistration = null; } export function unregisterAgentAwarenessDeviceForCurrentUser( tokenProvider: () => Promise, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-unregistration-identifier", + cause, + }), }); if (!deviceId) { return; @@ -397,7 +591,7 @@ export function unregisterAgentAwarenessDeviceForCurrentUser( export function registerLiveActivityPushToken(input: { readonly activity: LiveActivity; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities()) { return false; @@ -405,7 +599,11 @@ export function registerLiveActivityPushToken(input: { const activityPushToken = yield* Effect.tryPromise({ try: () => input.activity.getPushToken(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-live-activity-push-token", + cause, + }), }); if (!activityPushToken) { if (activityPushTokenListeners.has(input.activity)) { @@ -449,11 +647,15 @@ export function registerLiveActivityPushToken(input: { function registerLiveActivityPushTokenValue(input: { readonly activityPushToken: string; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-live-activity-registration-identifier", + cause, + }), }); const registered = yield* registerLiveActivityWithRelay({ deviceId, @@ -485,7 +687,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void { export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< void, never, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { @@ -494,7 +696,11 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< const activities = yield* Effect.try({ try: () => AgentActivity.getInstances(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "list-active-live-activities", + cause, + }), }).pipe( Effect.catch((error) => Effect.sync(() => { diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx new file mode 100644 index 00000000000..d560f8db9fa --- /dev/null +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -0,0 +1,95 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; +import { useFocusEffect } from "expo-router"; +import { useCallback, useMemo, useState } from "react"; + +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; +import { useArchivedThreadListActions } from "../home/useThreadListActions"; +import { + ArchivedThreadsScreen, + type ArchivedThreadsHeaderEnvironment, +} from "./ArchivedThreadsScreen"; +import { buildArchivedThreadGroups, type ArchivedThreadSortOrder } from "./archivedThreadList"; +import { + refreshArchivedThreadsForEnvironment, + useArchivedThreadSnapshots, +} from "./useArchivedThreadSnapshots"; + +export function ArchivedThreadsRouteScreen() { + const { expand } = useClerkSettingsSheetDetent(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const [sortOrder, setSortOrder] = useState("newest"); + const environments = useMemo>( + () => + Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + })), + Order.mapInput(Order.String, (environment: ArchivedThreadsHeaderEnvironment) => + environment.label.toLocaleLowerCase(), + ), + ), + [savedConnectionsById], + ); + const environmentIds = useMemo( + () => environments.map((environment) => environment.environmentId), + [environments], + ); + const environmentLabels = useMemo( + () => + Object.fromEntries( + environments.map((environment) => [environment.environmentId, environment.label]), + ), + [environments], + ); + const { error, isLoading, refresh, snapshots } = useArchivedThreadSnapshots(environmentIds); + const groups = useMemo( + () => + buildArchivedThreadGroups({ + snapshots, + environmentLabels, + environmentId: selectedEnvironmentId, + searchQuery, + sortOrder, + }), + [environmentLabels, searchQuery, selectedEnvironmentId, snapshots, sortOrder], + ); + const refreshChangedEnvironment = useCallback( + (thread: { readonly environmentId: EnvironmentId }) => { + refreshArchivedThreadsForEnvironment(thread.environmentId); + }, + [], + ); + const { unarchiveThread, confirmDeleteThread } = + useArchivedThreadListActions(refreshChangedEnvironment); + + useFocusEffect( + useCallback(() => { + expand(); + refresh(); + }, [expand, refresh]), + ); + + return ( + + ); +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx new file mode 100644 index 00000000000..3e1934100cd --- /dev/null +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -0,0 +1,436 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; +import * as Haptics from "expo-haptics"; +import { Stack } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useRef } from "react"; +import { + ActivityIndicator, + Pressable, + RefreshControl, + ScrollView, + useWindowDimensions, + View, +} from "react-native"; +import ReanimatedSwipeable, { + type SwipeableMethods, +} from "react-native-gesture-handler/ReanimatedSwipeable"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { EmptyState } from "../../components/EmptyState"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + THREAD_SWIPE_ACTIONS_WIDTH, + THREAD_SWIPE_SPRING, + ThreadSwipeActions, +} from "../home/thread-swipe-actions"; +import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; + +export interface ArchivedThreadsHeaderEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +const THREAD_ACTIONS: MenuAction[] = [ + { + id: "unarchive", + title: "Unarchive", + image: "arrow.uturn.backward", + }, + { + id: "delete", + title: "Delete", + image: "trash", + attributes: { destructive: true }, + }, +]; + +function ArchivedThreadsHeader(props: { + readonly environments: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly sortOrder: ArchivedThreadSortOrder; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onSearchQueryChange: (query: string) => void; + readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; +}) { + const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; + + return ( + <> + { + props.onSearchQueryChange(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + }, + }} + /> + + + + + Environment + props.onEnvironmentChange(null)} + > + All environments + + {props.environments.map((environment) => ( + props.onEnvironmentChange(environment.environmentId)} + > + {environment.label} + + ))} + + + + Sort by archived date + props.onSortOrderChange("newest")} + > + Newest first + + props.onSortOrderChange("oldest")} + > + Oldest first + + + + + + ); +} + +function ProjectGroupLabel(props: { + readonly environmentLabel: string | null; + readonly project: EnvironmentProject; +}) { + return ( + + + + {props.project.title} + + {props.environmentLabel ? ( + + {props.environmentLabel} + + ) : null} + + ); +} + +function ArchivedThreadRow(props: { + readonly environmentLabel: string | null; + readonly isLast: boolean; + readonly onDelete: () => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onUnarchive: () => void; + readonly thread: EnvironmentThreadShell; +}) { + const swipeableRef = useRef(null); + const fullSwipeArmedRef = useRef(false); + const { width: windowWidth } = useWindowDimensions(); + const cardColor = useThemeColor("--color-card"); + const iconColor = useThemeColor("--color-icon-subtle"); + const separatorColor = useThemeColor("--color-separator"); + const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); + const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); + const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => + Boolean(part), + ); + const handleFullSwipeArmedChange = useCallback((armed: boolean) => { + if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + fullSwipeArmedRef.current = armed; + }, []); + const handleMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + if (event.nativeEvent.event === "unarchive") { + props.onUnarchive(); + } else if (event.nativeEvent.event === "delete") { + props.onDelete(); + } + }, + [props.onDelete, props.onUnarchive], + ); + + return ( + { + fullSwipeArmedRef.current = false; + if (swipeableRef.current) { + props.onSwipeableClose(swipeableRef.current); + } + }} + onSwipeableOpenStartDrag={() => { + if (swipeableRef.current) { + props.onSwipeableWillOpen(swipeableRef.current); + } + }} + onSwipeableWillOpen={() => { + const methods = swipeableRef.current; + if (!methods) return; + + props.onSwipeableWillOpen(methods); + if (fullSwipeArmedRef.current) { + fullSwipeArmedRef.current = false; + methods.close(); + props.onDelete(); + } + }} + overshootFriction={1} + overshootRight + renderRightActions={(_progress, translation, methods) => ( + + )} + rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + > + + + + + + + + + {props.thread.title} + + + {timestamp} + + + {subtitle.length > 0 ? ( + + + + {subtitle.join(" · ")} + + + ) : null} + + + + + + + + + + ); +} + +function ArchiveError(props: { readonly message: string; readonly onRetry: () => void }) { + return ( + + + Could not load every archive + + {props.message} + + Try again + + + ); +} + +export function ArchivedThreadsScreen(props: { + readonly environments: ReadonlyArray; + readonly error: string | null; + readonly groups: ReadonlyArray; + readonly isLoading: boolean; + readonly searchQuery: string; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly sortOrder: ArchivedThreadSortOrder; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onRefresh: () => void; + readonly onSearchQueryChange: (query: string) => void; + readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; + readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; +}) { + const openSwipeableRef = useRef(null); + const refreshTint = useThemeColor("--color-icon"); + const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current && openSwipeableRef.current !== methods) { + openSwipeableRef.current.close(); + } + openSwipeableRef.current = methods; + }, []); + const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current === methods) { + openSwipeableRef.current = null; + } + }, []); + const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; + const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null; + + return ( + + + + openSwipeableRef.current?.close()} + refreshControl={ + + } + showsVerticalScrollIndicator={false} + > + {props.error ? : null} + + {isInitialLoad ? ( + + + Loading archive… + + ) : props.groups.length === 0 ? ( + + ) : ( + props.groups.map((group) => { + const environmentLabel = + props.environments.find( + (environment) => environment.environmentId === group.project.environmentId, + )?.label ?? null; + + return ( + + + + {group.threads.map((thread, index) => ( + props.onDeleteThread(thread)} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + onUnarchive={() => props.onUnarchiveThread(thread)} + thread={thread} + /> + ))} + + + ); + }) + )} + + + ); +} diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts new file mode 100644 index 00000000000..129a94b43af --- /dev/null +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -0,0 +1,145 @@ +import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; +import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildArchivedThreadGroups } from "./archivedThreadList"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeProject( + input: Partial & Pick, +): OrchestrationProjectShell { + return { + workspaceRoot: `/workspaces/${input.id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + ...input, + }; +} + +function makeThread( + input: Partial & + Pick, +): OrchestrationThreadShell { + return { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: "2026-06-02T00:00:00.000Z", + session: null, + goal: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +function makeSnapshot( + projects: ReadonlyArray, + threads: ReadonlyArray, + targetEnvironmentId = environmentId, +): ArchivedSnapshotEntry { + return { + environmentId: targetEnvironmentId, + snapshot: { + snapshotSequence: 1, + projects, + threads, + updatedAt: "2026-06-04T00:00:00.000Z", + }, + }; +} + +describe("buildArchivedThreadGroups", () => { + it("groups archived threads by project and sorts newest first", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const older = makeThread({ + id: ThreadId.make("thread-older"), + projectId: project.id, + title: "Older", + }); + const newer = makeThread({ + archivedAt: "2026-06-03T00:00:00.000Z", + id: ThreadId.make("thread-newer"), + projectId: project.id, + title: "Newer", + }); + + const result = buildArchivedThreadGroups({ + snapshots: [makeSnapshot([project], [older, newer])], + environmentLabels: { [environmentId]: "Julius's MacBook Pro" }, + environmentId: null, + searchQuery: "", + sortOrder: "newest", + }); + + expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-newer", "thread-older"]); + }); + + it("filters by environment and matches project, thread, and branch text", () => { + const secondEnvironmentId = EnvironmentId.make("environment-2"); + const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const secondProject = makeProject({ id: ProjectId.make("project-2"), title: "Website" }); + const firstThread = makeThread({ + branch: "fix/archive-screen", + id: ThreadId.make("thread-1"), + projectId: firstProject.id, + title: "Build settings route", + }); + const secondThread = makeThread({ + id: ThreadId.make("thread-2"), + projectId: secondProject.id, + title: "Unrelated", + }); + const snapshots = [ + makeSnapshot([firstProject], [firstThread]), + makeSnapshot([secondProject], [secondThread], secondEnvironmentId), + ]; + + const result = buildArchivedThreadGroups({ + snapshots, + environmentLabels: { + [environmentId]: "Local", + [secondEnvironmentId]: "Remote", + }, + environmentId, + searchQuery: "archive-screen", + sortOrder: "oldest", + }); + + expect(result).toHaveLength(1); + expect(result[0]?.project.environmentId).toBe(environmentId); + expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-1"]); + }); + + it("ignores non-archived entries returned in a snapshot", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const active = makeThread({ + archivedAt: null, + id: ThreadId.make("thread-active"), + projectId: project.id, + title: "Active", + }); + + const result = buildArchivedThreadGroups({ + snapshots: [makeSnapshot([project], [active])], + environmentLabels: {}, + environmentId: null, + searchQuery: "", + sortOrder: "newest", + }); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/archive/archivedThreadList.ts b/apps/mobile/src/features/archive/archivedThreadList.ts new file mode 100644 index 00000000000..6146bba2044 --- /dev/null +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -0,0 +1,106 @@ +import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; +import { + scopeProject, + scopeThreadShell, + type EnvironmentProject, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; + +export type ArchivedThreadSortOrder = "newest" | "oldest"; + +export interface ArchivedThreadGroup { + readonly key: string; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; +} + +function archiveTimestamp(thread: EnvironmentThreadShell): number { + const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function matchesQuery(value: string | null, query: string): boolean { + return value?.toLocaleLowerCase().includes(query) ?? false; +} + +export function buildArchivedThreadGroups(input: { + readonly snapshots: ReadonlyArray; + readonly environmentLabels: Readonly>; + readonly environmentId: EnvironmentId | null; + readonly searchQuery: string; + readonly sortOrder: ArchivedThreadSortOrder; +}): ReadonlyArray { + const query = input.searchQuery.trim().toLocaleLowerCase(); + const groups: ArchivedThreadGroup[] = []; + + for (const entry of input.snapshots) { + if (input.environmentId !== null && input.environmentId !== entry.environmentId) { + continue; + } + + const environmentLabel = input.environmentLabels[entry.environmentId] ?? null; + const threadsByProjectId = new Map(); + for (const thread of entry.snapshot.threads) { + if (thread.archivedAt === null) { + continue; + } + const threads = threadsByProjectId.get(thread.projectId) ?? []; + threads.push(scopeThreadShell(entry.environmentId, thread)); + threadsByProjectId.set(thread.projectId, threads); + } + + for (const rawProject of entry.snapshot.projects) { + const project = scopeProject(entry.environmentId, rawProject); + const projectThreads = threadsByProjectId.get(project.id) ?? []; + const groupMatches = + query.length === 0 || + matchesQuery(project.title, query) || + matchesQuery(project.workspaceRoot, query) || + matchesQuery(environmentLabel, query); + const matchingThreads = groupMatches + ? projectThreads + : projectThreads.filter( + (thread) => matchesQuery(thread.title, query) || matchesQuery(thread.branch, query), + ); + + if (matchingThreads.length === 0) { + continue; + } + + const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + groups.push({ + key: scopedProjectKey(project.environmentId, project.id), + project, + threads: Arr.sort( + matchingThreads, + Order.mapInput( + Order.Struct({ timestamp: timestampOrder, title: Order.String, id: Order.String }), + (thread: EnvironmentThreadShell) => ({ + timestamp: archiveTimestamp(thread), + title: thread.title, + id: thread.id, + }), + ), + ), + }); + } + } + + const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + return Arr.sort( + groups, + Order.mapInput( + Order.Struct({ timestamp: timestampOrder, title: Order.String, key: Order.String }), + (group: ArchivedThreadGroup) => ({ + timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0, + title: group.project.title, + key: group.key, + }), + ), + ); +} diff --git a/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts b/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts new file mode 100644 index 00000000000..d18cc230c63 --- /dev/null +++ b/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts @@ -0,0 +1,47 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type ArchivedSnapshotEntry, + createArchivedThreadSnapshotsAtomFamily, + makeArchivedThreadsEnvironmentKey, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useMemo } from "react"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { orchestrationEnvironment } from "../../state/orchestration"; + +function archivedSnapshotAtom(environmentId: EnvironmentId) { + return orchestrationEnvironment.archivedShellSnapshot({ + environmentId, + input: {}, + }); +} + +const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: archivedSnapshotAtom, + labelPrefix: "mobile:archived-thread-snapshots", +}); + +export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); +} + +export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { + readonly snapshots: ReadonlyArray; + readonly error: string | null; + readonly isLoading: boolean; + readonly refresh: () => void; +} { + const environmentKey = useMemo( + () => makeArchivedThreadsEnvironmentKey(environmentIds), + [environmentIds], + ); + const result = useAtomValue(archivedSnapshotsAtom(environmentKey)); + const refresh = useCallback(() => { + for (const environmentId of environmentIds) { + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); + } + }, [environmentIds]); + + return { ...result, refresh }; +} diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts new file mode 100644 index 00000000000..2bc62d2a34e --- /dev/null +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts @@ -0,0 +1,60 @@ +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { activateCloudRelayAccount, deactivateCloudRelayAccount } from "./CloudAuthProvider"; +import { setAgentAwarenessRelayTokenProvider } from "../agent-awareness/remoteRegistration"; + +vi.mock("@clerk/expo", () => ({ + ClerkProvider: vi.fn(), + useAuth: vi.fn(), +})); + +vi.mock("@clerk/expo/token-cache", () => ({ + tokenCache: {}, +})); + +vi.mock("../../lib/runtime", () => ({ + runtime: { + runPromiseExit: vi.fn(), + }, +})); + +vi.mock("../../connection/catalog", () => ({ + environmentCatalog: { + removeRelayEnvironments: {}, + }, +})); + +vi.mock("./publicConfig", () => ({ + resolveCloudPublicConfig: vi.fn(() => ({ + clerk: { publishableKey: null }, + relay: { url: null }, + })), + resolveRelayClerkTokenOptions: vi.fn(), +})); + +vi.mock("../agent-awareness/remoteRegistration", () => ({ + setAgentAwarenessRelayTokenProvider: vi.fn(), + unregisterAgentAwarenessDeviceForCurrentUser: vi.fn(), +})); + +afterEach(() => { + deactivateCloudRelayAccount(); + vi.clearAllMocks(); +}); + +describe("CloudAuthProvider relay account isolation", () => { + it("clears relay and agent-awareness credentials before cleanup can fail", async () => { + const tokenProvider = async () => "account-1-token"; + activateCloudRelayAccount("account-1", tokenProvider); + expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1"); + + deactivateCloudRelayAccount(); + const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined); + + expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull(); + expect(vi.mocked(setAgentAwarenessRelayTokenProvider)).toHaveBeenLastCalledWith(null); + await cleanup; + }); +}); diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index 5fc3b96fdc8..c89aeb9249a 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,63 +1,149 @@ import { ClerkProvider, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; -import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; +import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { + reportAtomCommandResult, + settleAsyncResult, + settlePromise, +} from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; import { type ReactNode, useEffect, useRef } from "react"; -import { mobileRuntime } from "../../lib/runtime"; +import { environmentCatalog } from "../../connection/catalog"; +import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; +import { useAtomCommand } from "../../state/use-atom-command"; import { setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; +function resetManagedRelayTokenCache() { + return settleAsyncResult(() => + runtime.runPromiseExit( + ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ), + ); +} + +export function deactivateCloudRelayAccount(): void { + setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); +} + +export function activateCloudRelayAccount( + accountId: string, + tokenProvider: () => Promise, +): void { + setAgentAwarenessRelayTokenProvider(tokenProvider, accountId); + setManagedRelaySession(appAtomRegistry, { + accountId, + readClerkToken: tokenProvider, + }); +} + function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { + reportFailure: false, + reportDefect: false, + }); const previousTokenProviderRef = useRef<{ readonly userId: string; readonly provider: () => Promise; } | null>(null); + const observedAccountRef = useRef(undefined); + const accountTransitionRef = useRef | null>(null); useEffect(() => { + let cancelled = false; if (!isLoaded) { return; } + + const previousObservedAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + + const queueAccountCleanup = ( + previous: { + readonly userId: string; + readonly provider: () => Promise; + } | null, + ) => { + const previousTransition = accountTransitionRef.current ?? Promise.resolve(); + accountTransitionRef.current = previousTransition.then(async () => { + const cleanup = [ + resetManagedRelayTokenCache(), + removeRelayEnvironments(), + ...(previous + ? [ + settleAsyncResult(() => + runtime.runPromiseExit( + unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), + ), + ), + ] + : []), + ]; + const results = await Promise.all(cleanup); + for (const result of results) { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + } + }); + return accountTransitionRef.current; + }; + if (!isSignedIn || !userId) { const previous = previousTokenProviderRef.current; previousTokenProviderRef.current = null; - if (previous) { - void mobileRuntime - .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) - .catch(() => undefined); + deactivateCloudRelayAccount(); + if (previousObservedAccount !== null) { + void queueAccountCleanup(previous); } - setAgentAwarenessRelayTokenProvider(null); - setManagedRelaySession(appAtomRegistry, null); return; } const previous = previousTokenProviderRef.current; - if (previous && previous.userId !== userId) { - void mobileRuntime - .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) - .catch(() => undefined); - } const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); - previousTokenProviderRef.current = { userId, provider: tokenProvider }; - setAgentAwarenessRelayTokenProvider(tokenProvider, userId); - setManagedRelaySession( - appAtomRegistry, - createManagedRelaySession({ - accountId: userId, - readClerkToken: tokenProvider, - }), - ); - }, [getToken, isLoaded, isSignedIn, userId]); + const activateSession = () => { + if (cancelled) { + return; + } + previousTokenProviderRef.current = { userId, provider: tokenProvider }; + activateCloudRelayAccount(userId, tokenProvider); + }; + const activateAfterTransition = (transition: Promise) => { + void (async () => { + const result = await settlePromise(async () => { + await transition; + activateSession(); + }); + reportAtomCommandResult(result, { label: "cloud account activation" }); + })(); + }; + if ( + previousObservedAccount !== undefined && + previousObservedAccount !== null && + previousObservedAccount !== userId + ) { + previousTokenProviderRef.current = null; + deactivateCloudRelayAccount(); + activateAfterTransition(queueAccountCleanup(previous)); + } else { + activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); + } + + return () => { + cancelled = true; + }; + }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]); useEffect( () => () => { previousTokenProviderRef.current = null; - setAgentAwarenessRelayTokenProvider(null); - setManagedRelaySession(appAtomRegistry, null); + deactivateCloudRelayAccount(); }, [], ); @@ -72,8 +158,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) { useEffect(() => { if (!publishableKey || !relayUrl) { - setAgentAwarenessRelayTokenProvider(null); - setManagedRelaySession(appAtomRegistry, null); + deactivateCloudRelayAccount(); } }, [publishableKey, relayUrl]); diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx index 1528a8fb97f..4d5b5703329 100644 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx @@ -2,7 +2,9 @@ import { useWaitlist } from "@clerk/expo"; import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { useState } from "react"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThemeColor } from "../../lib/useThemeColor"; +import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { const { errors, fetchStatus, waitlist } = useWaitlist(); @@ -20,12 +22,14 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } setRequestError(null); try { - const { error } = await waitlist.join({ emailAddress: normalizedEmailAddress }); - if (error) { - setRequestError("Could not join the waitlist. Check your email address and try again."); - } - } catch { - setRequestError("Could not join the waitlist. Check your connection and try again."); + await joinCloudWaitlist(waitlist, normalizedEmailAddress); + } catch (error) { + console.error(error); + setRequestError( + error instanceof CloudWaitlistJoinRejectedError + ? "Could not join the waitlist. Check your email address and try again." + : "Could not join the waitlist. Check your connection and try again.", + ); } }; @@ -141,12 +145,11 @@ function useCloudWaitlistColors() { const styles = StyleSheet.create({ body: { fontFamily: "DMSans_400Regular", - fontSize: 15, - lineHeight: 21, + ...MOBILE_TYPOGRAPHY.body, }, buttonText: { fontFamily: "DMSans_700Bold", - fontSize: 16, + fontSize: MOBILE_TYPOGRAPHY.body.fontSize, }, content: { gap: 18, @@ -156,8 +159,7 @@ const styles = StyleSheet.create({ }, error: { fontFamily: "DMSans_400Regular", - fontSize: 13, - lineHeight: 18, + ...MOBILE_TYPOGRAPHY.footnote, }, field: { gap: 8, @@ -167,15 +169,14 @@ const styles = StyleSheet.create({ borderRadius: 16, borderWidth: 1, fontFamily: "DMSans_400Regular", - fontSize: 17, + fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, minHeight: 54, paddingHorizontal: 16, paddingVertical: 14, }, label: { fontFamily: "DMSans_700Bold", - fontSize: 13, - lineHeight: 18, + ...MOBILE_TYPOGRAPHY.footnote, }, primaryButton: { alignItems: "center", @@ -196,13 +197,11 @@ const styles = StyleSheet.create({ }, signInText: { fontFamily: "DMSans_700Bold", - fontSize: 15, - lineHeight: 21, + ...MOBILE_TYPOGRAPHY.body, }, title: { fontFamily: "DMSans_700Bold", - fontSize: 20, - lineHeight: 26, + ...MOBILE_TYPOGRAPHY.title, textAlign: "center", }, }); diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts new file mode 100644 index 00000000000..840a3db5568 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts @@ -0,0 +1,18 @@ +export function isCloudDebugEnabled(): boolean { + return ( + (typeof __DEV__ !== "undefined" && __DEV__) || + (typeof globalThis !== "undefined" && + (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) + ); +} + +export function cloudDebugLog(event: string, data?: Record): void { + if (!isCloudDebugEnabled()) { + return; + } + if (data) { + console.log(`[t3-cloud] ${event}`, data); + } else { + console.log(`[t3-cloud] ${event}`); + } +} diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts new file mode 100644 index 00000000000..05a34cc9835 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts @@ -0,0 +1,88 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; + +import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation"; + +function relayStatus( + status: RelayEnvironmentStatusResponse["status"], + error?: string, + traceId?: string, +): RelayEnvironmentStatusResponse { + return { + environmentId: EnvironmentId.make("environment-cloud"), + endpoint: { + httpBaseUrl: "https://cloud.example.test/", + wsBaseUrl: "wss://cloud.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status, + checkedAt: "2026-06-05T16:49:11.000Z", + ...(error ? { error } : {}), + ...(traceId ? { traceId } : {}), + }; +} + +describe("available cloud environment presentation", () => { + it("presents an online unsaved environment as available, not connected", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: relayStatus("online"), + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Relay online", + }); + }); + + it("keeps relay status checks distinct from connection attempts", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: true, + status: null, + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Checking relay status...", + }); + }); + + it("surfaces an offline relay as an error", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: relayStatus("offline", "Tunnel is unavailable.", "trace-offline"), + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: "Tunnel is unavailable.", + connectionErrorTraceId: "trace-offline", + connectionState: "error", + statusText: "Tunnel is unavailable.", + }); + }); + + it("preserves trace metadata for relay request failures", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: null, + statusError: "Could not get relay environment status.", + statusErrorTraceId: "trace-status", + }), + ).toMatchObject({ + connectionError: "Could not get relay environment status.", + connectionErrorTraceId: "trace-status", + }); + }); +}); diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts new file mode 100644 index 00000000000..8a734c9b935 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts @@ -0,0 +1,53 @@ +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + +export interface AvailableCloudEnvironmentPresentation { + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly statusText: string; +} + +export function availableCloudEnvironmentPresentation(input: { + readonly isStatusPending: boolean; + readonly status: RelayEnvironmentStatusResponse | null; + readonly statusError: string | null; + readonly statusErrorTraceId: string | null; +}): AvailableCloudEnvironmentPresentation { + if (input.status?.status === "online") { + return { + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Relay online", + }; + } + + if (input.status?.status === "offline") { + const connectionError = input.status.error ?? "Relay is offline."; + return { + connectionError, + connectionErrorTraceId: input.status.traceId ?? null, + connectionState: "error", + statusText: connectionError, + }; + } + + if (input.statusError) { + return { + connectionError: input.statusError, + connectionErrorTraceId: input.statusErrorTraceId, + connectionState: "error", + statusText: input.statusError, + }; + } + + return { + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: input.isStatusPending + ? "Available · Checking relay status..." + : "Available · Relay status unknown", + }; +} diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts new file mode 100644 index 00000000000..582cb40ffbf --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + CloudWaitlistJoinRejectedError, + CloudWaitlistJoinRequestError, + joinCloudWaitlist, +} from "./cloudWaitlistJoin"; + +describe("joinCloudWaitlist", () => { + it("submits the provided email address", async () => { + const join = vi.fn().mockResolvedValue({ error: null }); + + await joinCloudWaitlist({ join }, "person@example.com"); + + expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" }); + }); + + it("preserves Clerk rejection details without exposing the email address", async () => { + const cause = Object.assign(new Error("The enrollment was rejected."), { + code: "form_identifier_invalid", + }); + const join = vi.fn().mockResolvedValue({ error: cause }); + + const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError); + expect(failure).toMatchObject({ + code: "form_identifier_invalid", + cause, + }); + expect(String(failure)).not.toContain("secret@example.com"); + }); + + it("distinguishes request failures from rejected enrollments", async () => { + const cause = new Error("network unavailable"); + const join = vi.fn().mockRejectedValue(cause); + + const failure = await joinCloudWaitlist({ join }, "person@example.com").catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError); + expect(failure).toMatchObject({ cause }); + expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError); + }); +}); diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts new file mode 100644 index 00000000000..4a467a19e4b --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts @@ -0,0 +1,46 @@ +import * as Schema from "effect/Schema"; + +interface CloudWaitlistJoiner { + readonly join: (input: { emailAddress: string }) => Promise<{ + readonly error: { readonly code: string } | null; + }>; +} + +export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass()( + "CloudWaitlistJoinRejectedError", + { + code: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Cloud waitlist enrollment was rejected with code "${this.code}".`; + } +} + +export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass()( + "CloudWaitlistJoinRequestError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Cloud waitlist enrollment request failed."; + } +} + +export async function joinCloudWaitlist( + waitlist: CloudWaitlistJoiner, + emailAddress: string, +): Promise { + const result = await waitlist.join({ emailAddress }).catch((cause) => { + throw new CloudWaitlistJoinRequestError({ cause }); + }); + + if (result.error) { + throw new CloudWaitlistJoinRejectedError({ + code: result.error.code, + cause: result.error, + }); + } +} diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index 8eda21b96ce..8945d148ee9 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -12,7 +12,7 @@ import { createDpopProof, generateDpopProofKeyPair, loadOrCreateDpopProofKeyPair, - mobileCryptoLayer, + cryptoLayer, } from "./dpop"; vi.mock("expo-crypto", () => ({ @@ -75,7 +75,7 @@ describe("mobile DPoP", () => { expect(Buffer.from(digest).toString("hex")).toBe( NodeCrypto.createHash("sha256").update("typed-array").digest("hex"), ); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("persists and reuses the installation proof key", () => @@ -86,7 +86,7 @@ describe("mobile DPoP", () => { expect(second.thumbprint).toBe(first.thumbprint); expect(second.privateJwk).toEqual(first.privateJwk); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("rejects malformed persisted proof keys", () => @@ -96,7 +96,7 @@ describe("mobile DPoP", () => { const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); expect(error.message).toBe("Stored DPoP proof key is invalid."); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () => @@ -135,7 +135,7 @@ describe("mobile DPoP", () => { nowEpochSeconds: proofIat(bootstrap.proof), }), ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("signs DPoP proofs with RFC 9449 htu normalization", () => @@ -161,6 +161,6 @@ describe("mobile DPoP", () => { nowEpochSeconds: proofIat(proof.proof), }), ).toMatchObject({ ok: true }); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); }); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 0a3d7c2a5a7..0bd4b7ff1bd 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -70,7 +70,7 @@ function toExpoDigestAlgorithm( } } -export const mobileCryptoLayer = Layer.succeed( +export const cryptoLayer = Layer.succeed( Crypto.Crypto, Crypto.make({ randomBytes: ExpoCrypto.getRandomBytes, diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 36544cf46cc..b9ab3aeab05 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -4,12 +4,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { EnvironmentId } from "@t3tools/contracts"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { - managedRelayClientLayer, - ManagedRelayClient, - ManagedRelayDpopSigner, - remoteHttpClientLayer, -} from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { @@ -55,13 +51,15 @@ const savedConnection = { bearerToken: "local-bearer", }; +const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test"; + const createProofMock = vi.fn( (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => Effect.succeed(`dpop:${input.method}:${input.url}`), ); const testDpopSignerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("client-proof-key-thumbprint"), createProof: (input) => createProofMock(input), }), @@ -71,7 +69,7 @@ function cloudClientLayer() { const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); return Layer.mergeAll( httpClientLayer, - managedRelayClientLayer({ + ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayMobileClientId, }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), @@ -79,7 +77,11 @@ function cloudClientLayer() { } const withCloudServices = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + >, ) => effect.pipe(Effect.provide(cloudClientLayer())); function validLinkProof() { @@ -352,7 +354,7 @@ describe("mobile cloud link environment client", () => { }); vi.stubGlobal("fetch", fetchMock); - yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" })); + yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: stableClerkToken })); expect( fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), @@ -425,9 +427,11 @@ describe("mobile cloud link environment client", () => { yield* withCloudServices( Effect.gen(function* () { - const records = yield* listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }); + const records = yield* listCloudEnvironmentsWithStatus({ + clerkToken: stableClerkToken, + }); yield* connectCloudEnvironment({ - clerkToken: "clerk-token", + clerkToken: stableClerkToken, environment: records[0]!.environment, }); }), @@ -658,6 +662,7 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkError", message: "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", + traceId: "trace-test", }); expect(fetchMock).toHaveBeenCalledTimes(3); }), @@ -1003,6 +1008,7 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkError", message: "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + traceId: "trace-connect", }); }), ); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index bca1ac21bc7..a77ca628978 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -16,22 +16,19 @@ import { type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, - RelayProtectedError, type RelayDpopAccessTokenScope, type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; -import { - exchangeRemoteDpopAccessToken, - fetchRemoteEnvironmentDescriptor, - makeEnvironmentHttpApiClient, - ManagedRelayClient, - ManagedRelayDpopSigner, -} from "@t3tools/client-runtime"; - -import { mobileAuthClientMetadata } from "../../lib/authClientMetadata"; +import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; + +import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; import { resolveCloudPublicConfig } from "./publicConfig"; @@ -56,6 +53,7 @@ function readRelayUrl(): string | null { export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ readonly message: string; readonly cause?: unknown; + readonly traceId?: string; }> {} export interface CloudEnvironmentRecordWithStatus { @@ -64,7 +62,6 @@ export interface CloudEnvironmentRecordWithStatus { readonly statusError: string | null; } -const isRelayProtectedError = Schema.is(RelayProtectedError); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ EnvironmentHttpBadRequestError, @@ -82,11 +79,13 @@ const MANAGED_ENDPOINT_PROVIDER_KIND = function cloudEnvironmentLinkError(message: string) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); + const traceId = findErrorTraceId(cause); return new CloudEnvironmentLinkError({ message: environmentError ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` : withDevCause(message, cause), cause, + ...(traceId === null ? {} : { traceId }), }); }; } @@ -148,31 +147,24 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayAgentActivityPublishProofInvalidError": return `Relay rejected the agent activity publish proof (${error.reason}).`; case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; + return `Relay encountered an internal error (${error.reason}).`; } } function decodedRelayClientError(message: string) { - return (cause: unknown) => { - const relayError = findRelayProtectedError(cause); + return (cause: ManagedRelay.ManagedRelayClientError) => { + const relayError = + cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; + const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, + ...(traceId ? { traceId } : {}), }); }; } -function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { - if (isRelayProtectedError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findRelayProtectedError(cause.cause) : null; -} - function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { if (isEnvironmentCloudApiError(cause)) { return cause; @@ -267,7 +259,11 @@ function ensureConnectEndpointMatchesEnvironment(input: { export function linkEnvironmentToCloud(input: { readonly connection: SavedRemoteConnection; readonly clerkToken: string; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient +> { return Effect.gen(function* () { if (!input.connection.bearerToken) { return yield* new CloudEnvironmentLinkError({ @@ -276,7 +272,7 @@ export function linkEnvironmentToCloud(input: { } const localBearerToken = input.connection.bearerToken; const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: cloudEnvironmentLinkError("Could not load the mobile device id."), @@ -359,11 +355,11 @@ export function listCloudEnvironments(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient .listEnvironments({ @@ -380,11 +376,11 @@ export function getCloudEnvironmentStatus(input: { }): Effect.Effect< RelayEnvironmentStatusResponseType, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const status = yield* relayClient .getEnvironmentStatus({ clerkToken: input.clerkToken, @@ -419,7 +415,7 @@ export function loadCloudEnvironmentStatuses(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.forEach( input.environments, @@ -451,7 +447,7 @@ export function listCloudEnvironmentsWithStatus(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const environments = yield* listCloudEnvironments(input); @@ -462,23 +458,26 @@ export function listCloudEnvironmentsWithStatus(input: { }); } -function connectRelayManagedEnvironment(input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly expectedEnvironment?: RelayClientEnvironmentRecord; -}): Effect.Effect< - SavedRemoteConnection, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner -> { - return Effect.gen(function* () { - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; - - const deviceId = yield* Effect.tryPromise({ +const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( + function* () { + return yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: cloudEnvironmentLinkError("Could not load the mobile device id."), }); + }, +); + +const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")( + function* (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly expectedEnvironment?: RelayClientEnvironmentRecord; + }) { + yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId }); + const relayUrl = yield* requireRelayUrl(); + const relayClient = yield* ManagedRelay.ManagedRelayClient; + + const deviceId = yield* loadAgentAwarenessDeviceId(); const connect = yield* relayClient .connectEnvironment({ clerkToken: input.clerkToken, @@ -517,7 +516,7 @@ function connectRelayManagedEnvironment(input: { message: "Connected endpoint descriptor does not match the selected environment.", }); } - const signer = yield* ManagedRelayDpopSigner; + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; const bootstrapDpop = yield* signer .createProof({ method: "POST", @@ -528,7 +527,7 @@ function connectRelayManagedEnvironment(input: { httpBaseUrl: connect.endpoint.httpBaseUrl, credential: connect.credential, dpopProof: bootstrapDpop, - clientMetadata: mobileAuthClientMetadata(), + clientMetadata: authClientMetadata(), }).pipe( Effect.mapError( cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), @@ -548,9 +547,9 @@ function connectRelayManagedEnvironment(input: { authenticationMethod: "dpop", dpopAccessToken: bootstrap.access_token, relayManaged: true, - }; - }); -} + } satisfies SavedRemoteConnection; + }, +); export function connectCloudEnvironment(input: { readonly clerkToken: string; @@ -558,7 +557,7 @@ export function connectCloudEnvironment(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, @@ -573,7 +572,7 @@ export function refreshCloudEnvironmentConnection(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 0de43d049c5..2da1fa9157c 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -1,42 +1,62 @@ -import { - managedRelayClientLayer, - ManagedRelayDpopSigner, - ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; +import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; -const mobileRelayDpopSignerLayer = Layer.effect( - ManagedRelayDpopSigner, +const relayDpopSignerLayer = Layer.effect( + ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - return ManagedRelayDpopSigner.of({ - thumbprint: Effect.suspend(() => - loadOrCreateDpopProofKeyPair().pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + const loadProofKey = yield* Effect.cached( + loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), + ); + return ManagedRelay.ManagedRelayDpopSigner.of({ + thumbprint: loadProofKey.pipe( + Effect.map((proofKey) => proofKey.thumbprint), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + keyStore: "expo-secure-store", + cause: error, + }), ), + Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"), ), - createProof: (input) => - Effect.gen(function* () { - const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe( - Effect.provideService(Crypto.Crypto, crypto), - ); - return yield* createDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proof) => proof.proof), - ); - }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))), + createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(function* (input) { + const proofKey = yield* loadProofKey.pipe( + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + return yield* createDpopProof({ ...input, proofKey }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proof) => proof.proof), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + }), }); }), ); -export const mobileManagedRelayClientLayer = (relayUrl: string) => - managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe( - Layer.provideMerge(mobileRelayDpopSignerLayer), - ); +export const managedRelayClientLayer = (relayUrl: string) => + ManagedRelay.layer({ + relayUrl, + clientId: RelayMobileClientId, + accessTokenStore: managedRelayAccessTokenStore, + }).pipe(Layer.provideMerge(relayDpopSignerLayer)); diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 3394a519fd6..eec1e3410e6 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -3,20 +3,24 @@ import { createManagedRelayQueryManager, managedRelaySessionAtom, readManagedRelaySnapshotState, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import type { RelayClientEnvironmentRecord, RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; -import { mobileRuntimeContextLayer } from "../../lib/runtime"; +import { runtimeContextLayer } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; +import { cloudDebugLog } from "./cloudDebugLog"; -const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer); +const managedRelayAtomRuntime = Atom.runtime(runtimeContextLayer); -export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime, { + onQueryEvent: (event) => + cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), +}); const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), @@ -33,6 +37,15 @@ export function useManagedRelayEnvironments() { ? managedRelayQueryManager.environmentsAtom(accountId) : EMPTY_ENVIRONMENTS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); @@ -40,7 +53,7 @@ export function useManagedRelayEnvironments() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; @@ -53,6 +66,16 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) : EMPTY_ENVIRONMENT_STATUS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment status failed", { + environmentId: environment.environmentId, + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [environment.environmentId, snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { @@ -63,7 +86,7 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron }, [accountId, environment]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts new file mode 100644 index 00000000000..9642e5f63ae --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts @@ -0,0 +1,85 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import * as SecureStore from "expo-secure-store"; +import { vi } from "vite-plus/test"; + +const secureStore = vi.hoisted(() => new Map()); + +vi.mock("expo-secure-store", () => ({ + getItemAsync: vi.fn((key: string) => Promise.resolve(secureStore.get(key) ?? null)), + setItemAsync: vi.fn((key: string, value: string) => { + secureStore.set(key, value); + return Promise.resolve(); + }), + deleteItemAsync: vi.fn((key: string) => { + secureStore.delete(key); + return Promise.resolve(); + }), +})); + +import { + ManagedRelayTokenStoreError, + managedRelayAccessTokenStore, +} from "./managedRelayTokenStore"; + +it.effect("round-trips and clears persisted managed relay access tokens", () => + Effect.gen(function* () { + secureStore.clear(); + const entries = [ + { + accountId: "user-1", + clientId: "t3-mobile", + relayUrl: "https://relay.example.test", + thumbprint: "thumbprint", + scopes: ["environment:connect"], + accessToken: "access-token", + expiresAtMillis: 1_800_000, + }, + ] as const; + + yield* managedRelayAccessTokenStore.save(entries); + expect(yield* managedRelayAccessTokenStore.load).toEqual(entries); + + yield* managedRelayAccessTokenStore.clear; + expect(yield* managedRelayAccessTokenStore.load).toEqual([]); + }), +); + +it.effect("falls back to an empty cache when persisted data is invalid", () => + Effect.gen(function* () { + secureStore.clear(); + secureStore.set("t3code.cloud.relay-access-tokens", "not-json"); + + expect(yield* managedRelayAccessTokenStore.load).toEqual([]); + }), +); + +it.effect("logs structured storage failures before falling back to an empty cache", () => { + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + const cause = new Error("secure store unavailable"); + vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(cause); + + return Effect.gen(function* () { + expect(yield* managedRelayAccessTokenStore.load).toEqual([]); + + const message = messages.find( + (candidate) => + Array.isArray(candidate) && candidate[0] === "Managed relay token store operation failed.", + ); + expect(message).toBeDefined(); + const context = (message as ReadonlyArray)[1] as { + readonly cause: ManagedRelayTokenStoreError; + }; + expect(context.cause).toBeInstanceOf(ManagedRelayTokenStoreError); + expect(context.cause).toMatchObject({ + operation: "read", + storageKey: "t3code.cloud.relay-access-tokens", + cause, + }); + expect(context.cause.message).not.toContain(cause.message); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); +}); diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts new file mode 100644 index 00000000000..0730f277f3c --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -0,0 +1,133 @@ +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SecureStore from "expo-secure-store"; + +const MANAGED_RELAY_TOKEN_CACHE_KEY = "t3code.cloud.relay-access-tokens"; +const MANAGED_RELAY_TOKEN_CACHE_VERSION = 1; + +const ManagedRelayAccessTokenCacheEntrySchema = Schema.Struct({ + accountId: Schema.String, + clientId: Schema.Literals(["t3-mobile", "t3-web"]), + relayUrl: Schema.String, + thumbprint: Schema.String, + scopes: Schema.Array( + Schema.Literals(["environment:connect", "environment:status", "mobile:registration"]), + ), + accessToken: Schema.String, + expiresAtMillis: Schema.Number, +}); + +const ManagedRelayAccessTokenCacheSchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(MANAGED_RELAY_TOKEN_CACHE_VERSION), + entries: Schema.Array(ManagedRelayAccessTokenCacheEntrySchema), + }), +); + +const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect( + ManagedRelayAccessTokenCacheSchema, +); +const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema); + +export class ManagedRelayTokenStoreError extends Schema.TaggedErrorClass()( + "ManagedRelayTokenStoreError", + { + operation: Schema.Literals(["read", "decode", "encode", "write", "clear"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Managed relay token store operation "${this.operation}" failed for key "${this.storageKey}".`; + } +} + +function logStoreFailure(error: ManagedRelayTokenStoreError) { + return Effect.logWarning("Managed relay token store operation failed.", { + errorTag: error._tag, + operation: error.operation, + storageKey: error.storageKey, + cause: error, + }); +} + +const loadManagedRelayAccessTokens = Effect.tryPromise({ + try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "read", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), +}).pipe( + Effect.flatMap((encoded) => + encoded === null + ? Effect.succeed>([]) + : decodeManagedRelayAccessTokenCache(encoded).pipe( + Effect.map((cache) => cache.entries), + Effect.mapError( + (cause) => + new ManagedRelayTokenStoreError({ + operation: "decode", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), + ), + ), + ), +); + +const saveManagedRelayAccessTokens = ( + entries: ReadonlyArray, +) => + encodeManagedRelayAccessTokenCache({ + version: MANAGED_RELAY_TOKEN_CACHE_VERSION, + entries, + }).pipe( + Effect.mapError( + (cause) => + new ManagedRelayTokenStoreError({ + operation: "encode", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), + ), + Effect.flatMap((encoded) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "write", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), + }), + ), + ); + +const clearManagedRelayAccessTokens = Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "clear", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), +}); + +export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { + load: loadManagedRelayAccessTokens.pipe( + Effect.tapError(logStoreFailure), + Effect.orElseSucceed(() => []), + Effect.withSpan("mobile.managedRelayTokenStore.load"), + ), + save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) => + saveManagedRelayAccessTokens(entries).pipe(Effect.tapError(logStoreFailure), Effect.ignore), + ), + clear: clearManagedRelayAccessTokens.pipe( + Effect.tapError(logStoreFailure), + Effect.ignore, + Effect.withSpan("mobile.managedRelayTokenStore.clear"), + ), +}; diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index d5094d71b8b..05bf1a8fbcc 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; +import { + CloudPublicConfigMissingError, + hasTracingPublicConfig, + resolveCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "./publicConfig"; vi.mock("expo-constants", () => ({ default: { @@ -11,6 +16,12 @@ vi.mock("expo-constants", () => ({ })); describe("resolveCloudPublicConfig", () => { + it("reports the missing Clerk JWT template as structured configuration", () => { + expect(() => resolveRelayClerkTokenOptions()).toThrowError( + new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), + ); + }); + it("returns no cloud configuration for an unconfigured build", () => { expect(resolveCloudPublicConfig({})).toEqual({ clerk: { @@ -94,9 +105,9 @@ describe("resolveCloudPublicConfig", () => { }); it("keeps tracing disabled unless every public tracing value is configured", () => { - expect(hasMobileTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); + expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); expect( - hasMobileTracingPublicConfig( + hasTracingPublicConfig( resolveCloudPublicConfig({ observability: { tracesUrl: "https://api.axiom.co/v1/traces", @@ -106,7 +117,7 @@ describe("resolveCloudPublicConfig", () => { ), ).toBe(false); expect( - hasMobileTracingPublicConfig( + hasTracingPublicConfig( resolveCloudPublicConfig({ observability: { tracesUrl: "https://api.axiom.co/v1/traces", diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index 7a8822eb9db..93a78fa4f44 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -1,6 +1,18 @@ import Constants from "expo-constants"; import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Schema from "effect/Schema"; + +export class CloudPublicConfigMissingError extends Schema.TaggedErrorClass()( + "CloudPublicConfigMissingError", + { + key: Schema.Literal("T3CODE_CLERK_JWT_TEMPLATE"), + }, +) { + override get message(): string { + return `${this.key} is not configured.`; + } +} export interface CloudPublicConfig { readonly clerk: { @@ -70,13 +82,13 @@ type Configured = { readonly [Key in keyof T]: NonNullable; }; -type MobileTracingPublicConfig = Omit & { +type TracingPublicConfig = Omit & { readonly observability: Configured; }; -export function hasMobileTracingPublicConfig( +export function hasTracingPublicConfig( config: CloudPublicConfig = resolveCloudPublicConfig(), -): config is MobileTracingPublicConfig { +): config is TracingPublicConfig { return Boolean( config.observability.tracesUrl && config.observability.tracesDataset && @@ -87,7 +99,7 @@ export function hasMobileTracingPublicConfig( export function resolveRelayClerkTokenOptions() { const { jwtTemplate } = resolveCloudPublicConfig().clerk; if (!jwtTemplate) { - throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); + throw new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }); } return relayClerkTokenOptions(jwtTemplate); } diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index dd26e2e6ffb..7b901ec4c66 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,31 +1,26 @@ import { SymbolView } from "expo-symbols"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; -import { Pressable, View } from "react-native"; +import { Alert, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { - if (environment.connectionError) { - return null; - } - - switch (environment.connectionState) { - case "ready": - return "Connected"; - case "connecting": - return "Connecting"; - case "reconnecting": - return "Reconnecting"; - case "disconnected": - return null; - case "idle": - return null; - } + return connectionStatusText({ + phase: environment.connectionState, + error: environment.connectionError, + traceId: environment.connectionErrorTraceId, + }); } export function ConnectionEnvironmentRow(props: { @@ -37,7 +32,7 @@ export function ConnectionEnvironmentRow(props: { readonly onUpdate: ( environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, - ) => void; + ) => Promise>; }) { const [label, setLabel] = useState(props.environment.environmentLabel); const [url, setUrl] = useState(props.environment.displayUrl); @@ -47,13 +42,25 @@ export function ConnectionEnvironmentRow(props: { const primaryFg = useThemeColor("--color-primary-foreground"); const dangerFg = useThemeColor("--color-danger-foreground"); const statusLabel = connectionStatusLabel(props.environment); - - const handleSave = useCallback(() => { - props.onUpdate(props.environment.environmentId, { + const statusTraceId = props.environment.connectionErrorTraceId; + const hasConnectionFailure = props.environment.connectionError !== null; + const isRetrying = + props.environment.connectionState === "connecting" || + props.environment.connectionState === "reconnecting"; + const handleSave = useCallback(async () => { + const result = await props.onUpdate(props.environment.environmentId, { label: label.trim(), displayUrl: url.trim(), }); - props.onToggle(); + if (AsyncResult.isSuccess(result)) { + props.onToggle(); + return; + } + const error = Cause.squash(result.cause); + Alert.alert( + "Could not update environment", + error instanceof Error ? error.message : "The environment could not be updated.", + ); }, [label, url, props]); return ( @@ -64,34 +71,47 @@ export function ConnectionEnvironmentRow(props: { > - + {props.environment.environmentLabel} - + {props.environment.displayUrl} {statusLabel ? ( - - {statusLabel} - - ) : null} - {props.environment.connectionError ? ( - {props.environment.connectionError} + {statusLabel} + {statusTraceId ? ( + <> + {" Trace ID: "} + { + event.stopPropagation(); + copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); + }} + onPress={(event) => { + event.stopPropagation(); + }} + style={{ textDecorationStyle: "dotted" }} + > + {statusTraceId} + + + ) : null} ) : null} @@ -114,14 +134,14 @@ export function ConnectionEnvironmentRow(props: { className="gap-3 px-4 pb-4" > {props.environment.isRelayManaged ? ( - + Managed by T3 Cloud. Tunnel details update automatically. ) : ( <> Label @@ -133,13 +153,13 @@ export function ConnectionEnvironmentRow(props: { placeholderTextColor={placeholderColor} value={label} onChangeText={setLabel} - className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" + className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-base text-foreground" /> URL @@ -152,7 +172,7 @@ export function ConnectionEnvironmentRow(props: { placeholderTextColor={placeholderColor} value={url} onChangeText={setUrl} - className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" + className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-base text-foreground" /> @@ -166,7 +186,7 @@ export function ConnectionEnvironmentRow(props: { > Save diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index 1a03061e23f..8a692d80729 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -104,7 +104,7 @@ export function ConnectionSheetButton(props: { type="monochrome" /> {props.label} diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index 60d86e0118c..ce5c6a6419e 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -11,12 +11,19 @@ import Animated, { import type { RemoteClientConnectionState } from "../../lib/connection"; -function statusDotTone(state: RemoteClientConnectionState): { +export type ConnectionStatusDotState = RemoteClientConnectionState; + +function statusDotTone(state: ConnectionStatusDotState): { readonly dotColor: string; readonly haloColor: string; } { switch (state) { - case "ready": + case "available": + return { + dotColor: "#9ca3af", + haloColor: "rgba(156,163,175,0.42)", + }; + case "connected": return { dotColor: "#34d399", haloColor: "rgba(52,211,153,0.48)", @@ -27,8 +34,8 @@ function statusDotTone(state: RemoteClientConnectionState): { dotColor: "#f59e0b", haloColor: "rgba(245,158,11,0.5)", }; - case "idle": - case "disconnected": + case "offline": + case "error": return { dotColor: "#ef4444", haloColor: "rgba(239,68,68,0.48)", @@ -63,7 +70,7 @@ function usePulseAnimation(pulse: boolean) { } export function ConnectionStatusDot(props: { - readonly state: RemoteClientConnectionState; + readonly state: ConnectionStatusDotState; readonly pulse: boolean; readonly size?: number; }) { diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx new file mode 100644 index 00000000000..373b0d3ef03 --- /dev/null +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -0,0 +1,112 @@ +import { + type EnvironmentConnectionPhase, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; +import { SymbolView } from "expo-symbols"; +import { ActivityIndicator, Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useThemeColor } from "../../lib/useThemeColor"; + +function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string { + switch (phase) { + case "offline": + return "You are offline"; + case "connecting": + return `Connecting to ${environmentLabel}...`; + case "reconnecting": + return `Reconnecting to ${environmentLabel}...`; + case "error": + return `${environmentLabel} is unavailable`; + case "available": + return `${environmentLabel} is disconnected`; + case "connected": + return ""; + } +} + +function noticeDetail( + phase: EnvironmentConnectionPhase, + resourceName: string, + error: string | null, +): string { + if (error) { + return `The app will keep retrying automatically. ${error}`; + } + + switch (phase) { + case "offline": + return `Cached data remains available. The ${resourceName} will load when your connection returns.`; + case "connecting": + case "reconnecting": + return `The ${resourceName} will load as soon as the environment is ready.`; + case "available": + case "error": + return `Reconnect the environment to load the ${resourceName}.`; + case "connected": + return ""; + } +} + +export function EnvironmentConnectionNotice(props: { + readonly environmentLabel: string; + readonly connection: EnvironmentConnectionPresentation; + readonly resourceName: string; + readonly onRetry: () => void; +}) { + const iconColor = String(useThemeColor("--color-icon-muted")); + const isRetrying = + props.connection.phase === "connecting" || props.connection.phase === "reconnecting"; + + return ( + + + {isRetrying ? ( + + ) : ( + + )} + + + {noticeTitle(props.connection.phase, props.environmentLabel)} + + + {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} + {props.connection.traceId ? ( + <> + {" Trace ID: "} + + copyTextWithHaptic(props.connection.traceId!, { + target: "connection-trace-id", + }) + } + > + {props.connection.traceId} + + + ) : null} + + + {props.connection.phase !== "offline" ? ( + + Retry now + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts index 5e17b469de2..0de49ceabf6 100644 --- a/apps/mobile/src/features/connection/connectionTone.ts +++ b/apps/mobile/src/features/connection/connectionTone.ts @@ -3,7 +3,7 @@ import type { RemoteClientConnectionState } from "../../lib/connection"; export function connectionTone(state: RemoteClientConnectionState): StatusTone { switch (state) { - case "ready": + case "connected": return { label: "Connected", pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", @@ -21,15 +21,21 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", textClassName: "text-sky-700 dark:text-sky-300", }; - case "disconnected": + case "error": return { - label: "Disconnected", + label: "Connection failed", pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", textClassName: "text-rose-700 dark:text-rose-300", }; - case "idle": + case "offline": return { - label: "Idle", + label: "Offline", + pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", + textClassName: "text-rose-700 dark:text-rose-300", + }; + case "available": + return { + label: "Available", pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", textClassName: "text-neutral-600 dark:text-neutral-300", }; diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts new file mode 100644 index 00000000000..497af4bfac4 --- /dev/null +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -0,0 +1,130 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { splitEnvironmentSections } from "./environmentSections"; + +function connectedEnvironment( + input: Omit, "environmentId"> & { + readonly environmentId: string; + readonly isRelayManaged: boolean; + }, +): ConnectedEnvironmentSummary { + return { + environmentId: EnvironmentId.make(input.environmentId), + environmentLabel: input.environmentLabel ?? input.environmentId, + displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`, + isRelayManaged: input.isRelayManaged, + connectionState: input.connectionState ?? "connected", + connectionError: input.connectionError ?? null, + connectionErrorTraceId: input.connectionErrorTraceId ?? null, + }; +} + +function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { + return { + environmentId: EnvironmentId.make(environmentId), + label: environmentId, + endpoint: { + httpBaseUrl: `https://${environmentId}.cloud.example.test/`, + wsBaseUrl: `wss://${environmentId}.cloud.example.test/ws`, + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("mobile environment settings sections", () => { + it("keeps saved relay-managed connections under T3 Cloud", () => { + const local = connectedEnvironment({ + environmentId: "environment-local", + isRelayManaged: false, + }); + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud, local], + cloudEnvironments: [ + cloudEnvironment("environment-cloud"), + cloudEnvironment("environment-new"), + ], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-new")]); + }); + + it("keeps saved relay-managed connections visible when cloud listing is unavailable", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "reconnecting", + connectionError: "Environment did not respond before the connection timeout.", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: null, + }); + + expect(sections.localEnvironments).toEqual([]); + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("keeps an available saved relay environment as a fallback when listing is unavailable", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "available", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: null, + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("does not duplicate a saved relay environment in the available cloud listing", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "available", + }); + const listedCloud = cloudEnvironment("environment-cloud"); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: [listedCloud], + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("keeps failed relay environments in the local connection row", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "error", + connectionError: "Connection failed.", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: [cloudEnvironment("environment-cloud")], + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts new file mode 100644 index 00000000000..fc6db479c2f --- /dev/null +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -0,0 +1,31 @@ +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; + +export interface EnvironmentSectionsInput { + readonly connectedEnvironments: ReadonlyArray; + readonly cloudEnvironments: ReadonlyArray | null; +} + +export interface EnvironmentSections { + readonly localEnvironments: ReadonlyArray; + readonly connectedCloudEnvironments: ReadonlyArray; + readonly availableCloudEnvironments: ReadonlyArray; +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const savedEnvironmentIds = new Set( + input.connectedEnvironments.map((environment) => environment.environmentId), + ); + + return { + localEnvironments: input.connectedEnvironments.filter( + (environment) => !environment.isRelayManaged, + ), + connectedCloudEnvironments: input.connectedEnvironments.filter( + (environment) => environment.isRelayManaged, + ), + availableCloudEnvironments: (input.cloudEnvironments ?? []).filter( + (environment) => !savedEnvironmentIds.has(environment.environmentId), + ), + }; +} diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 028c46c1ce5..18b6c71a293 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; +import { + extractPairingUrlFromQrPayload, + PairingQrPayloadEmptyError, + parsePairingUrl, +} from "./pairing"; describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { @@ -18,7 +22,8 @@ describe("extractPairingUrlFromQrPayload", () => { }); it("rejects empty qr payloads", () => { - expect(() => extractPairingUrlFromQrPayload(" ")).toThrow( + expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError(PairingQrPayloadEmptyError); + expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError( "Scanned QR code did not contain a pairing URL.", ); }); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index f7362900b0c..910efa7f256 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -1,7 +1,17 @@ import { readHostedPairingRequest } from "@t3tools/shared/remote"; +import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; +export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( + "PairingQrPayloadEmptyError", + {}, +) { + override get message(): string { + return "Scanned QR code did not contain a pairing URL."; + } +} + export function buildPairingUrl(host: string, code: string): string { const h = host.trim(); const c = code.trim(); @@ -48,7 +58,7 @@ export function parsePairingUrl(url: string): { host: string; code: string } { export function extractPairingUrlFromQrPayload(payload: string): string { const trimmed = payload.trim(); if (!trimmed) { - throw new Error("Scanned QR code did not contain a pairing URL."); + throw new PairingQrPayloadEmptyError({}); } try { diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts new file mode 100644 index 00000000000..bad6b6f1720 --- /dev/null +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -0,0 +1,125 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + RelayConnectionRegistration, + RelayConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import * as Option from "effect/Option"; +import { useCallback, useMemo } from "react"; + +import { environmentCatalog } from "../../connection/catalog"; +import { + connectPairingUrl as connectPairingUrlAtom, + updateBearerConnection, +} from "../../connection/onboarding"; +import { useEnvironments } from "../../state/environments"; +import { relayEnvironmentDiscovery } from "../../state/relay"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; + +export interface RelayEnvironmentView { + readonly environment: RelayClientEnvironmentRecord; + readonly availability: "checking" | "online" | "offline" | "error"; + readonly status: RelayEnvironmentStatusResponse | null; + readonly error: string | null; + readonly traceId: string | null; +} + +export function useConnectionController() { + const { environments } = useEnvironments(); + const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom); + const connectPairingUrlMutation = useAtomCommand(connectPairingUrlAtom, { + reportFailure: false, + }); + const updateBearer = useAtomCommand(updateBearerConnection, { reportFailure: false }); + const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register"); + const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove"); + const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const refreshRelayEnvironments = useAtomCommand( + relayEnvironmentDiscovery.refresh, + "relay environment refresh", + ); + + const connectedEnvironments = useMemo>( + () => environments.map(projectWorkspaceEnvironment), + [environments], + ); + const registeredIds = useMemo( + () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), + [connectedEnvironments], + ); + const relayEnvironments = useMemo>( + () => + [...discovery.environments.values()].map((entry) => ({ + environment: entry.environment, + availability: entry.availability, + status: Option.getOrNull(entry.status), + error: Option.getOrNull(entry.error)?.message ?? null, + traceId: Option.getOrNull(entry.error)?.traceId ?? null, + })), + [discovery.environments], + ); + const availableRelayEnvironments = useMemo( + () => relayEnvironments.filter((entry) => !registeredIds.has(entry.environment.environmentId)), + [registeredIds, relayEnvironments], + ); + + const connectPairingUrl = useCallback( + (pairingUrl: string) => connectPairingUrlMutation(pairingUrl), + [connectPairingUrlMutation], + ); + const connectRelayEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => + registerEnvironment( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: environment.environmentId, + label: environment.label, + }), + }), + ), + [registerEnvironment], + ); + const removeEnvironment = useCallback( + (environmentId: EnvironmentId) => removeEnvironmentMutation(environmentId), + [removeEnvironmentMutation], + ); + const retryEnvironment = useCallback( + (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId), + [retryEnvironmentMutation], + ); + const updateEnvironment = useCallback( + ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => + updateBearer({ + environmentId, + label: updates.label, + httpBaseUrl: updates.displayUrl, + }), + [updateBearer], + ); + + return { + connectedEnvironments, + relayEnvironments, + availableRelayEnvironments, + relayDiscovery: { + isRefreshing: discovery.refreshing, + isOffline: discovery.offline, + error: Option.getOrNull(discovery.error)?.message ?? null, + errorTraceId: Option.getOrNull(discovery.error)?.traceId ?? null, + }, + connectPairingUrl, + connectRelayEnvironment, + removeEnvironment, + retryEnvironment, + updateEnvironment, + refreshRelayEnvironments, + }; +} diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 72abcc8c956..6c8c957f541 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -8,6 +8,7 @@ import jsxLanguage from "@shikijs/langs/jsx"; import tsxLanguage from "@shikijs/langs/tsx"; import typescriptLanguage from "@shikijs/langs/typescript"; import yamlLanguage from "@shikijs/langs/yaml"; +import * as Schema from "effect/Schema"; import type { NativeReviewDiffFile, NativeReviewDiffLanguage } from "./nativeReviewDiffTypes"; import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface"; @@ -15,6 +16,32 @@ import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewD export type NativeReviewDiffHighlightScheme = "light" | "dark"; export type NativeReviewDiffHighlightEngine = "native" | "javascript"; +export class NativeReviewDiffHighlighterUnavailableError extends Schema.TaggedErrorClass()( + "NativeReviewDiffHighlighterUnavailableError", + {}, +) { + override get message(): string { + return "The native review diff highlighter is unavailable in this build."; + } +} + +export const isNativeReviewDiffHighlighterUnavailableError = Schema.is( + NativeReviewDiffHighlighterUnavailableError, +); + +export class NativeReviewDiffHighlighterInitializationError extends Schema.TaggedErrorClass()( + "NativeReviewDiffHighlighterInitializationError", + { + requestedEngine: Schema.Literals(["native", "javascript"]), + attemptedEngine: Schema.Literals(["native", "javascript"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the ${this.attemptedEngine} review diff highlighter requested as ${this.requestedEngine}.`; + } +} + export interface NativeReviewDiffHighlighterHandle { readonly engine: NativeReviewDiffHighlightEngine; readonly tokenize: ( @@ -197,7 +224,7 @@ function normalizeTokens( async function createNativeReviewDiffHighlighter(): Promise { const nativeEngineModule = await import("react-native-shiki-engine"); if (!nativeEngineModule.isNativeEngineAvailable()) { - throw new Error("Native Shiki engine is not available in this build."); + throw new NativeReviewDiffHighlighterUnavailableError(); } const highlighter = await createHighlighterCore({ @@ -229,18 +256,52 @@ export async function getNativeReviewDiffHighlighter( engine: NativeReviewDiffHighlightEngine = "native", ): Promise { if (engine === "javascript") { - javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); - return javascriptHighlighterPromise; + try { + javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); + return await javascriptHighlighterPromise; + } catch (cause) { + javascriptHighlighterPromise = null; + throw new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "javascript", + cause, + }); + } } - nativeHighlighterPromise ??= createNativeReviewDiffHighlighter().catch((error: unknown) => { - console.warn("[debug-native-diff] native highlighter unavailable", { - error: error instanceof Error ? error.message : String(error), + nativeHighlighterPromise ??= createNativeReviewDiffHighlighter() + .catch(async (cause: unknown) => { + const nativeError = isNativeReviewDiffHighlighterUnavailableError(cause) + ? cause + : new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "native", + cause, + }); + console.warn("[debug-native-diff] native highlighter unavailable", { + error: nativeError, + }); + try { + javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); + return await javascriptHighlighterPromise; + } catch (fallbackCause) { + javascriptHighlighterPromise = null; + throw new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "javascript", + cause: new AggregateError( + [nativeError, fallbackCause], + "Native and JavaScript review diff highlighter initialization failed.", + { cause: nativeError }, + ), + }); + } + }) + .catch((error) => { + nativeHighlighterPromise = null; + throw error; }); - javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); - return javascriptHighlighterPromise; - }); - return nativeHighlighterPromise; + return await nativeHighlighterPromise; } function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow { diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 65e7539340d..975bf7be13d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -58,10 +58,23 @@ describe("resolveNativeReviewDiffView", () => { it("returns null when the view manager cannot be required", async () => { setExpoViewConfigAvailable(); + const cause = new Error("boom"); expoMocks.requireNativeView.mockImplementation(() => { - throw new Error("boom"); + throw cause; }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); + + expect(resolveNativeReviewDiffView()).toBeNull(); expect(resolveNativeReviewDiffView()).toBeNull(); + expect(expoMocks.requireNativeView).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NativeViewResolutionError", + nativeModuleName: "T3ReviewDiffSurface", + cause, + }), + ); + expect(consoleError).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 7bd53f67748..7660a047752 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -2,6 +2,8 @@ import type { ComponentType } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView } from "expo"; +import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; + const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; interface ExpoGlobalWithViewConfig { @@ -110,6 +112,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly styleJson?: string; readonly rowHeight: number; readonly contentWidth: number; + readonly initialRowIndex?: number; readonly onDebug?: (event: NativeSyntheticEvent>) => void; readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; @@ -127,6 +130,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { } let cachedNativeReviewDiffView: ComponentType | undefined; +let nativeReviewDiffViewResolutionFailed = false; function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( @@ -139,6 +143,10 @@ export function resolveNativeReviewDiffView(): ComponentType( NATIVE_REVIEW_DIFF_MODULE_NAME, ); - } catch { + } catch (cause) { + nativeReviewDiffViewResolutionFailed = true; + console.error( + new NativeViewResolutionError({ + nativeModuleName: NATIVE_REVIEW_DIFF_MODULE_NAME, + cause, + }), + ); return null; } diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx new file mode 100644 index 00000000000..ce762ab184e --- /dev/null +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -0,0 +1,173 @@ +import { useCallback, useMemo } from "react"; +import { + Markdown, + type CustomRenderers, + type NodeStyleOverrides, + type PartialMarkdownTheme, +} from "react-native-nitro-markdown"; +import { ScrollView, Text as NativeText, View } from "react-native"; + +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + hasNativeSelectableMarkdownText, + SelectableMarkdownText, + type NativeMarkdownTextStyle, +} from "../../native/SelectableMarkdownText"; + +interface MarkdownPreviewStyles { + readonly theme: PartialMarkdownTheme; + readonly styles: NodeStyleOverrides; + readonly renderers: CustomRenderers; + readonly nativeTextStyle: NativeMarkdownTextStyle; +} + +function useMarkdownPreviewStyles(): MarkdownPreviewStyles { + const body = String(useThemeColor("--color-md-body")); + const strong = String(useThemeColor("--color-md-strong")); + const link = String(useThemeColor("--color-md-link")); + const blockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); + const blockquoteBackground = String(useThemeColor("--color-md-blockquote-bg")); + const codeBackground = String(useThemeColor("--color-md-code-bg")); + const codeText = String(useThemeColor("--color-md-code-text")); + const horizontalRule = String(useThemeColor("--color-md-hr")); + + return useMemo(() => { + const renderers: CustomRenderers = { + link: ({ href, children }) => ( + { + if (href) { + void tryOpenExternalUrl(href, "markdown-link"); + } + }} + style={{ + color: link, + fontFamily: "DMSans_500Medium", + textDecorationLine: "none", + }} + > + {children} + + ), + }; + + return { + theme: { + colors: { + text: body, + heading: strong, + link, + blockquote: blockquoteBorder, + border: horizontalRule, + surfaceLight: blockquoteBackground, + accent: link, + tableBorder: horizontalRule, + tableHeader: blockquoteBackground, + tableHeaderText: strong, + code: codeText, + codeBackground, + }, + }, + styles: { + text: { + color: body, + fontFamily: "DMSans_400Regular", + ...MOBILE_TYPOGRAPHY.body, + }, + heading: { + color: strong, + fontFamily: "DMSans_700Bold", + }, + strong: { + color: strong, + fontFamily: "DMSans_700Bold", + }, + link: { + color: link, + fontFamily: "DMSans_500Medium", + }, + blockquote: { + backgroundColor: blockquoteBackground, + borderLeftColor: blockquoteBorder, + borderLeftWidth: 3, + paddingLeft: 12, + }, + code: { + backgroundColor: codeBackground, + color: codeText, + fontFamily: "ui-monospace", + }, + codeBlock: { + backgroundColor: codeBackground, + borderRadius: 12, + color: codeText, + fontFamily: "ui-monospace", + padding: 12, + }, + hr: { + backgroundColor: horizontalRule, + }, + }, + renderers, + nativeTextStyle: { + color: body, + strongColor: strong, + mutedColor: body, + linkColor: link, + inlineCodeColor: codeText, + codeColor: codeText, + codeBackgroundColor: codeBackground, + codeBlockBackgroundColor: codeBackground, + fileTextColor: codeText, + skillTextColor: codeText, + quoteMarkerColor: blockquoteBorder, + dividerColor: horizontalRule, + ...MOBILE_TYPOGRAPHY.body, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, + }; + }, [ + blockquoteBackground, + blockquoteBorder, + body, + codeBackground, + codeText, + horizontalRule, + link, + strong, + ]); +} + +export function FileMarkdownPreview(props: { readonly markdown: string }) { + const styles = useMarkdownPreviewStyles(); + const onLinkPress = useCallback((href: string) => { + void tryOpenExternalUrl(href, "markdown-link"); + }, []); + + return ( + + + {hasNativeSelectableMarkdownText() ? ( + + ) : ( + + {props.markdown} + + )} + + + ); +} diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx new file mode 100644 index 00000000000..3def77433b2 --- /dev/null +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -0,0 +1,189 @@ +import type { ProjectEntry } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { cn } from "../../lib/cn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + buildFileTree, + defaultExpandedTreePaths, + flattenFileTree, + type VisibleFileTreeNode, +} from "./fileTree"; + +function ancestorPaths(path: string): ReadonlyArray { + const parts = path.split("/").filter(Boolean); + const ancestors: string[] = []; + for (let index = 1; index < parts.length; index += 1) { + ancestors.push(parts.slice(0, index).join("/")); + } + return ancestors; +} + +const FileTreeRow = memo(function FileTreeRow(props: { + readonly item: VisibleFileTreeNode; + readonly selectedPath: string | null; + readonly expanded: boolean; + readonly iconColor: string; + readonly onPressDirectory: (path: string) => void; + readonly onPressFile: (path: string) => void; +}) { + const { node, depth } = props.item; + const selected = node.kind === "file" && node.path === props.selectedPath; + + return ( + { + if (node.kind === "directory") { + props.onPressDirectory(node.path); + return; + } + props.onPressFile(node.path); + }} + className={cn( + "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle", + selected && "bg-subtle-strong", + )} + style={{ paddingLeft: 8 + depth * 18 }} + > + {node.kind === "directory" ? ( + + ) : ( + + )} + + + {node.name} + + {node.kind === "directory" ? ( + + {node.children.length} + + ) : null} + + ); +}); + +export function FileTreeBrowser(props: { + readonly entries: ReadonlyArray; + readonly error: string | null; + readonly isPending: boolean; + readonly searchQuery: string; + readonly selectedPath: string | null; + readonly onRefresh: () => void; + readonly onSelectFile: (path: string) => void; +}) { + const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); + const iconColor = String(useThemeColor("--color-icon-muted")); + + const tree = useMemo(() => buildFileTree(props.entries), [props.entries]); + const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); + const visibleNodes = useMemo( + () => + flattenFileTree({ + nodes: tree, + expanded: expandedPaths, + searchQuery: props.searchQuery, + }), + [expandedPaths, props.searchQuery, tree], + ); + + useEffect(() => { + setExpandedPaths((current) => { + if (current.size > 0 || defaultExpanded.size === 0) { + return current; + } + return new Set(defaultExpanded); + }); + }, [defaultExpanded]); + + useEffect(() => { + if (!props.selectedPath) { + return; + } + setExpandedPaths((current) => { + const next = new Set(current); + for (const ancestor of ancestorPaths(props.selectedPath ?? "")) { + next.add(ancestor); + } + return next; + }); + }, [props.selectedPath]); + + const toggleDirectory = useCallback((path: string) => { + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + return ( + + {props.error && props.entries.length === 0 ? ( + + Files unavailable + {props.error} + + ) : ( + item.node.path} + contentInsetAdjustmentBehavior="automatic" + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + contentContainerStyle={{ paddingVertical: 8 }} + refreshControl={ + + } + renderItem={({ item }) => ( + + )} + ListEmptyComponent={ + + {props.isPending ? ( + + ) : ( + <> + No files found + + {props.searchQuery.trim().length > 0 + ? "Try a different search." + : "The workspace file index is empty."} + + + )} + + } + /> + )} + + ); +} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx new file mode 100644 index 00000000000..b96d6515951 --- /dev/null +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -0,0 +1,258 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { ComponentType } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { LoadingStrip } from "../../components/LoadingStrip"; +import { + type NativeReviewDiffViewProps, + resolveNativeReviewDiffView, +} from "../diffs/nativeReviewDiffSurface"; +import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; +import { + REVIEW_DIFF_LINE_HEIGHT, + REVIEW_MONO_FONT_FAMILY, + renderVisibleWhitespace, +} from "../review/reviewDiffRendering"; +import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; +import { cn } from "../../lib/cn"; +import { MOBILE_CODE_SURFACE } from "../../lib/typography"; +import { + buildNativeSourceRows, + buildNativeSourceTokens, + NATIVE_SOURCE_CONTENT_WIDTH, + NATIVE_SOURCE_ROW_HEIGHT, + NATIVE_SOURCE_STYLE, + nativeSourceRowId, +} from "./nativeSourceFileAdapter"; +import { sourceHighlightAtom } from "./sourceHighlightingState"; + +const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; +const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth; +const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE); + +interface SourceFileSurfaceProps { + readonly contents: string; + readonly path: string; + readonly initialLine?: number | null; +} + +type SourceHighlightStatus = "highlighting" | "ready" | "error"; + +function splitSourceLines(contents: string): ReadonlyArray { + return contents.replace(/\r\n?/g, "\n").split("\n"); +} + +const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { + readonly index: number; + readonly line: string; + readonly tokens: ReadonlyArray | null; + readonly highlighted: boolean; +}) { + return ( + + + {props.index + 1} + + + {props.tokens && props.tokens.length > 0 + ? (() => { + let offset = 0; + return props.tokens.map((token) => { + const start = offset; + offset += token.content.length; + + const fontWeight = + token.fontStyle !== null && (token.fontStyle & 2) === 2 + ? ("700" as const) + : ("400" as const); + const fontStyle = + token.fontStyle !== null && (token.fontStyle & 1) === 1 + ? ("italic" as const) + : ("normal" as const); + + return ( + + {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} + + ); + }); + })() + : renderVisibleWhitespace(props.line || " ")} + + + ); +}); + +function useSourceFileModel(props: SourceFileSurfaceProps) { + const colorScheme = useColorScheme(); + const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; + const normalizedContents = useMemo( + () => props.contents.replace(/\r\n?/g, "\n"), + [props.contents], + ); + const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]); + const targetIndex = + props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 + ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) + : null; + const highlightAtom = useMemo( + () => sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme }), + [normalizedContents, props.path, theme], + ); + const highlightResult = useAtomValue(highlightAtom); + const tokens = AsyncResult.isSuccess(highlightResult) ? highlightResult.value : null; + const status: SourceHighlightStatus = AsyncResult.isFailure(highlightResult) + ? "error" + : AsyncResult.isSuccess(highlightResult) + ? "ready" + : "highlighting"; + + return { lines, status, targetIndex, theme, tokens }; +} + +function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { + if (props.status === "highlighting") { + return ; + } + if (props.status === "error") { + return ( + + Plain text + + ); + } + return null; +} + +function NativeSourceFileSurface( + props: SourceFileSurfaceProps & { + readonly NativeView: ComponentType; + }, +) { + const { NativeView } = props; + const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props); + const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]); + const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); + const selectedRowIdsJson = useMemo( + () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), + [targetIndex], + ); + const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); + + return ( + + + + + ); +} + +function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const { lines, status, targetIndex, tokens } = useSourceFileModel(props); + const listRef = useRef>(null); + + useEffect(() => { + if (targetIndex === null) { + return; + } + const frame = requestAnimationFrame(() => { + listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 }); + }); + return () => cancelAnimationFrame(frame); + }, [props.path, targetIndex]); + + const renderLine = useCallback( + ({ item, index }: { item: string; index: number }) => ( + + ), + [targetIndex, tokens], + ); + + return ( + + + + String(index)} + initialNumToRender={80} + maxToRenderPerBatch={80} + windowSize={12} + getItemLayout={(_data, index) => ({ + length: SOURCE_LINE_HEIGHT, + offset: SOURCE_LINE_HEIGHT * index, + index, + })} + contentContainerStyle={{ + minWidth: "100%", + paddingBottom: REVIEW_DIFF_LINE_HEIGHT, + paddingTop: 8, + }} + renderItem={renderLine} + /> + + + ); +} + +export function SourceFileSurface(props: SourceFileSurfaceProps) { + const NativeView = resolveNativeReviewDiffView(); + return NativeView ? ( + + ) : ( + + ); +} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx new file mode 100644 index 00000000000..fba032c0369 --- /dev/null +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -0,0 +1,626 @@ +import Stack from "expo-router/stack"; +import { SymbolView } from "expo-symbols"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; +import { + EnvironmentId, + type ProjectListEntriesResult, + type ProjectReadFileResult, + ThreadId, +} from "@t3tools/contracts"; + +import { AppText as Text } from "../../components/AppText"; +import { CopyTextButton } from "../../components/CopyTextButton"; +import { EmptyState } from "../../components/EmptyState"; +import { LoadingScreen } from "../../components/LoadingScreen"; +import { cn } from "../../lib/cn"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { buildThreadFilesNavigation } from "../../lib/routes"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useEnvironmentQuery } from "../../state/query"; +import { projectEnvironment } from "../../state/projects"; +import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; +import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { FileTreeBrowser } from "./FileTreeBrowser"; +import { SourceFileSurface } from "./SourceFileSurface"; +import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; +import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; +import { + basename, + fileBreadcrumbs, + isBrowserPreviewFile, + isImagePreviewFile, + isMarkdownPreviewFile, + isSvgImagePreviewFile, +} from "./filePath"; +import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; + +type FileViewMode = "preview" | "source"; + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +function normalizeRoutePath(value: string | string[] | undefined): string | null { + const path = Array.isArray(value) ? value.join("/") : value; + if (path === undefined || path.trim().length === 0) { + return null; + } + return path; +} + +function normalizeRouteLine(value: string | null): number | null { + if (value === null) { + return null; + } + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function defaultViewMode(path: string | null): FileViewMode { + return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path)) + ? "preview" + : "source"; +} + +function ModeButton(props: { + readonly active: boolean; + readonly icon: "doc.text" | "eye"; + readonly label: string; + readonly onPress: () => void; +}) { + const iconColor = String( + useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"), + ); + + return ( + + + + {props.label} + + + ); +} + +function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) { + const gradientId = `file-breadcrumb-${props.side}-fade`; + const isLeft = props.side === "left"; + + return ( + + + + + + + + + + + + ); +} + +function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) { + const iconColor = String(useThemeColor("--color-icon-muted")); + const cardColor = String(useThemeColor("--color-card")); + const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 }); + const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false }); + const breadcrumbs = useMemo( + () => fileBreadcrumbs(props.projectName, props.relativePath), + [props.projectName, props.relativePath], + ); + const updateFadeVisibility = useCallback( + (metrics: Partial<(typeof scrollMetrics)["current"]>) => { + Object.assign(scrollMetrics.current, metrics); + const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current; + const maxOffset = Math.max(0, contentWidth - viewportWidth); + const next = { + left: maxOffset > 1 && offsetX > 1, + right: maxOffset > 1 && offsetX < maxOffset - 1, + }; + + setFadeVisibility((current) => + current.left === next.left && current.right === next.right ? current : next, + ); + }, + [], + ); + + return ( + + { + updateFadeVisibility({ contentWidth }); + }} + onLayout={(event) => { + updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width }); + }} + onScroll={(event) => { + updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x }); + }} + scrollEventThrottle={16} + > + + {breadcrumbs.map((crumb, index) => ( + + {index > 0 ? ( + + ) : null} + + {crumb.label} + + + ))} + + + {fadeVisibility.left ? : null} + {fadeVisibility.right ? : null} + + ); +} + +function FilePreviewHeader(props: { + readonly activeMode: FileViewMode; + readonly showModeSelector: boolean; + readonly externalPreviewUri?: string | null; + readonly projectName: string; + readonly relativePath: string; + readonly onSetMode: (mode: FileViewMode) => void; +}) { + const iconColor = String(useThemeColor("--color-icon-muted")); + + return ( + + + + + + {props.showModeSelector ? ( + + props.onSetMode("preview")} + /> + props.onSetMode("source")} + /> + {props.externalPreviewUri !== undefined ? ( + { + if (typeof props.externalPreviewUri === "string") { + void tryOpenExternalUrl(props.externalPreviewUri, "file-preview"); + } + }} + > + + + ) : null} + + ) : null} + + ); +} + +function FileContent(props: { + readonly activeMode: FileViewMode; + readonly previewUri: string | null; + readonly fileContents: string | null; + readonly fileError: string | null; + readonly relativePath: string; + readonly initialLine: number | null; + readonly truncated: boolean; +}) { + const isMarkdown = isMarkdownPreviewFile(props.relativePath); + const isBrowserFile = isBrowserPreviewFile(props.relativePath); + const isImageFile = isImagePreviewFile(props.relativePath); + + if (props.activeMode === "preview" && isImageFile) { + if (isSvgImagePreviewFile(props.relativePath)) { + return ; + } + return ( + + ); + } + + if (props.activeMode === "preview" && isBrowserFile) { + return ; + } + + if (props.fileError && props.fileContents === null) { + return ( + + + + ); + } + + if (props.fileContents === null) { + return ( + + + Loading file... + + ); + } + + return ( + + {props.truncated ? ( + + + Partial file + + + Preview limited to the first 1 MB of a truncated file. + + + ) : null} + {props.activeMode === "preview" && isMarkdown ? ( + + ) : ( + + )} + + ); +} + +function useThreadFilesWorkspace() { + const params = useLocalSearchParams<{ + environmentId?: string | string[]; + threadId?: string | string[]; + }>(); + const routeEnvironmentId = firstRouteParam(params.environmentId); + const routeThreadId = firstRouteParam(params.threadId); + const { selectedThread, selectedThreadProject } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const environmentId = + routeEnvironmentId !== null + ? EnvironmentId.make(routeEnvironmentId) + : (selectedThread?.environmentId ?? null); + const threadId = routeThreadId !== null ? ThreadId.make(routeThreadId) : null; + const project = selectedThreadProject as { + readonly title?: string; + readonly workspaceRoot?: string; + } | null; + + return { + cwd: selectedThreadCwd ?? project?.workspaceRoot ?? null, + environmentId, + projectName: project?.title ?? "Files", + selectedThread, + threadId, + }; +} + +function FilesUnavailable() { + return ( + + + + + ); +} + +function FilesHeaderTitle(props: { readonly projectName: string }) { + const foregroundColor = String(useThemeColor("--color-foreground")); + const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary")); + + return ( + + + Files + + + {props.projectName} + + + ); +} + +function FilesToolbarBottomFade() { + const sheetColor = String(useThemeColor("--color-sheet")); + + if (process.env.EXPO_OS !== "ios") { + return null; + } + + return ( + + + + + + + + + + + + + ); +} + +export function ThreadFilesTreeScreen() { + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(""); + const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); + const entriesQuery = useEnvironmentQuery( + environmentId !== null && cwd !== null + ? projectEnvironment.listEntries({ + environmentId, + input: { cwd }, + }) + : null, + ); + const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + + const handleSelectFile = useCallback( + (path: string) => { + if (environmentId === null || threadId === null) { + return; + } + router.push(buildThreadFilesNavigation({ environmentId, threadId }, path)); + }, + [environmentId, router, threadId], + ); + + if (selectedThread === null || environmentId === null || threadId === null) { + return ; + } + + if (cwd === null) { + return ; + } + + return ( + + , + headerSearchBarOptions: { + allowToolbarIntegration: true, + autoCapitalize: "none", + hideNavigationBar: false, + placeholder: "Search files", + onChangeText: (event) => { + setSearchQuery(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + setSearchQuery(""); + }, + }, + }} + /> + + + + + + + + + + ); +} + +export function ThreadFileScreen() { + const params = useLocalSearchParams<{ + line?: string | string[]; + path?: string | string[]; + }>(); + const relativePath = normalizeRoutePath(params.path); + const targetLine = normalizeRouteLine(firstRouteParam(params.line)); + const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); + const [modeOverride, setModeOverride] = useState<{ + readonly path: string; + readonly mode: FileViewMode; + } | null>(null); + const [previewRevision, setPreviewRevision] = useState(0); + const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); + const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); + const canPreview = + relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile); + const activeMode = + relativePath !== null && modeOverride?.path === relativePath + ? modeOverride.mode + : defaultViewMode(relativePath); + const resolvedActiveMode = canPreview ? activeMode : "source"; + const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; + const assetPreviewUri = useWorkspaceFileAssetUrl({ + cwd, + environmentId, + relativePath: assetPreviewPath, + threadId, + }); + const previewUri = + assetPreviewUri === null || previewRevision === 0 + ? assetPreviewUri + : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; + const needsFileContents = + relativePath !== null && + (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); + const fileQuery = useEnvironmentQuery( + environmentId !== null && cwd !== null && relativePath !== null && needsFileContents + ? projectEnvironment.readFile({ + environmentId, + input: { cwd, relativePath }, + }) + : null, + ); + const fileData = fileQuery.data as ProjectReadFileResult | null; + + if (selectedThread === null || environmentId === null || threadId === null) { + return ; + } + + if (cwd === null) { + return ; + } + + if (relativePath === null) { + return ( + + + + + ); + } + + return ( + + + + + { + if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) { + setPreviewRevision((current) => current + 1); + return; + } + fileQuery.refresh(); + }} + /> + + { + setModeOverride({ path: relativePath, mode }); + }} + /> + + + + ); +} diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx new file mode 100644 index 00000000000..73eca66bf99 --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -0,0 +1,118 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useMemo, useState } from "react"; +import { ActivityIndicator, Image, Pressable, View } from "react-native"; +import ImageViewing from "react-native-image-viewing"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { AppText as Text } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { workspaceFileImageAtom } from "./workspace-file-image-cache"; + +function ResolvedWorkspaceFileImagePreview(props: { + readonly accessibilityLabel: string; + readonly uri: string; +}) { + const [loadError, setLoadError] = useState(null); + const [fullScreenVisible, setFullScreenVisible] = useState(false); + const imageSource = useMemo( + () => ({ uri: props.uri, cache: "force-cache" as const }), + [props.uri], + ); + const fullScreenImages = useMemo(() => [imageSource], [imageSource]); + + return ( + + setFullScreenVisible(true)} + > + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + + + {loadError !== null ? ( + + + + ) : null} + + setFullScreenVisible(false)} + swipeToCloseEnabled + doubleTapToZoomEnabled + /> + + ); +} + +function CachedWorkspaceFileImagePreview(props: { + readonly accessibilityLabel: string; + readonly uri: string; +}) { + const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); + const imageResult = useAtomValue(imageAtom); + + if (AsyncResult.isFailure(imageResult)) { + return ( + + + + ); + } + + if (!AsyncResult.isSuccess(imageResult)) { + return ( + + + Loading image... + + ); + } + + return ( + + ); +} + +export function WorkspaceFileImagePreview(props: { + readonly accessibilityLabel: string; + readonly uri: string | null; +}) { + if (props.uri === null) { + return ( + + + + Preparing image preview... + + + ); + } + + return ( + + ); +} diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx new file mode 100644 index 00000000000..6d03a23d52a --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { ActivityIndicator, View } from "react-native"; +import { WebView } from "react-native-webview"; + +import { AppText as Text } from "../../components/AppText"; +import { LoadingStrip } from "../../components/LoadingStrip"; + +export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) { + const [loadProgress, setLoadProgress] = useState(0); + const [loadError, setLoadError] = useState(null); + + if (props.uri === null) { + return ( + + + Preparing preview... + + ); + } + + return ( + + {loadProgress > 0 && loadProgress < 1 ? : null} + {loadError ? ( + + Preview failed + {loadError} + + ) : null} + { + setLoadProgress(event.nativeEvent.progress); + }} + onLoadStart={() => { + setLoadProgress(0.05); + setLoadError(null); + }} + onLoadEnd={() => { + setLoadProgress(0); + }} + onError={(event) => { + setLoadProgress(0); + setLoadError(event.nativeEvent.description || "The file could not be rendered."); + }} + renderLoading={() => ( + + + + )} + style={{ flex: 1, backgroundColor: "transparent" }} + /> + + ); +} diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts new file mode 100644 index 00000000000..af0ace61fc0 --- /dev/null +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isBrowserPreviewFile, + isImagePreviewFile, + isSvgImagePreviewFile, + resolveWorkspaceRelativeFilePath, +} from "./filePath"; + +describe("resolveWorkspaceRelativeFilePath", () => { + it("keeps normalized workspace-relative paths", () => { + expect(resolveWorkspaceRelativeFilePath("/repo", "./src/../src/main.ts")).toBe("src/main.ts"); + }); + + it("converts absolute paths inside the workspace", () => { + expect( + resolveWorkspaceRelativeFilePath("/Users/julius/repo", "/Users/julius/repo/src/main.ts"), + ).toBe("src/main.ts"); + expect(resolveWorkspaceRelativeFilePath("C:\\repo", "c:\\repo\\src\\main.ts")).toBe( + "src/main.ts", + ); + }); + + it("rejects paths outside the workspace", () => { + expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull(); + expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull(); + expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull(); + }); +}); + +describe("file preview types", () => { + it("recognizes browser and image previews", () => { + expect(isBrowserPreviewFile("reports/summary.html")).toBe(true); + expect(isImagePreviewFile("assets/icon.png")).toBe(true); + expect(isImagePreviewFile("assets/diagram.SVG?raw=1")).toBe(true); + expect(isImagePreviewFile("src/image.ts")).toBe(false); + }); + + it("identifies SVG images that need web rendering", () => { + expect(isSvgImagePreviewFile("assets/diagram.svg#icon")).toBe(true); + expect(isSvgImagePreviewFile("assets/photo.png")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts new file mode 100644 index 00000000000..385d5c139ee --- /dev/null +++ b/apps/mobile/src/features/files/filePath.ts @@ -0,0 +1,116 @@ +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, +} from "@t3tools/shared/filePreview"; + +export interface FileBreadcrumb { + readonly label: string; + readonly path: string; + readonly kind: "project" | "directory" | "file"; +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); +} + +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || isWindowsAbsolutePath(value); +} + +function isWindowsPathStyle(value: string): boolean { + return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value); +} + +function joinPath(base: string, next: string, separator: "/" | "\\"): string { + const cleanBase = base.replace(/[\\/]+$/, ""); + if (separator === "\\") { + return `${cleanBase}\\${next.replaceAll("/", "\\")}`; + } + return `${cleanBase}/${next.replace(/^\/+/, "")}`; +} + +export function basename(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean); + return parts.at(-1) ?? path; +} + +export function resolveWorkspaceFilePath(cwd: string, relativePath: string): string { + if (isAbsolutePath(relativePath)) { + return relativePath; + } + + const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; + return joinPath(cwd, relativePath, separator); +} + +function normalizeRelativePath(value: string): string | null { + const segments: string[] = []; + for (const segment of value.replaceAll("\\", "/").split("/")) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length === 0) { + return null; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.length > 0 ? segments.join("/") : null; +} + +export function resolveWorkspaceRelativeFilePath( + workspaceRoot: string | null | undefined, + targetPath: string, +): string | null { + if (!isAbsolutePath(targetPath)) { + if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) { + return null; + } + return normalizeRelativePath(targetPath); + } + if (!workspaceRoot) { + return null; + } + + const normalizedTarget = targetPath.replaceAll("\\", "/"); + const normalizedRoot = workspaceRoot.replaceAll("\\", "/").replace(/\/+$/, ""); + const caseInsensitive = isWindowsAbsolutePath(targetPath) || isWindowsAbsolutePath(workspaceRoot); + const comparableTarget = caseInsensitive ? normalizedTarget.toLowerCase() : normalizedTarget; + const comparableRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + if (!comparableTarget.startsWith(`${comparableRoot}/`)) { + return null; + } + + return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1)); +} + +export function isBrowserPreviewFile(path: string): boolean { + return isWorkspaceBrowserPreviewPath(path); +} + +export function isImagePreviewFile(path: string): boolean { + return isWorkspaceImagePreviewPath(path); +} + +export function isSvgImagePreviewFile(path: string): boolean { + return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); +} + +export function isMarkdownPreviewFile(path: string): boolean { + return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); +} + +export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] { + const parts = relativePath.split("/").filter(Boolean); + return [ + { label: projectName, path: "", kind: "project" }, + ...parts.map((part, index) => ({ + label: part, + path: parts.slice(0, index + 1).join("/"), + kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), + })), + ]; +} diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts new file mode 100644 index 00000000000..85383514cb5 --- /dev/null +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ProjectEntry } from "@t3tools/contracts"; + +import { + buildFileTree, + countFileNodes, + defaultExpandedTreePaths, + firstFilePath, + flattenFileTree, +} from "./fileTree"; + +const entries = [ + { kind: "file", path: "README.md" }, + { kind: "directory", path: "src" }, + { kind: "file", path: "src/index.ts" }, + { kind: "file", path: "src/components/App.tsx" }, + { kind: "file", path: "package.json" }, +] satisfies ReadonlyArray; + +describe("mobile file tree helpers", () => { + it("builds a deterministic hierarchy with directories before files", () => { + const tree = buildFileTree(entries); + + expect(tree.map((node) => `${node.kind}:${node.path}`)).toEqual([ + "directory:src", + "file:package.json", + "file:README.md", + ]); + expect(tree[0]?.children.map((node) => `${node.kind}:${node.path}`)).toEqual([ + "directory:src/components", + "file:src/index.ts", + ]); + expect(countFileNodes(tree)).toBe(4); + expect(firstFilePath(tree)).toBe("src/components/App.tsx"); + }); + + it("flattens expanded directories and hides collapsed descendants", () => { + const tree = buildFileTree(entries); + + expect( + flattenFileTree({ + nodes: tree, + expanded: new Set(["src"]), + }).map((item) => `${item.depth}:${item.node.path}`), + ).toEqual(["0:src", "1:src/components", "1:src/index.ts", "0:package.json", "0:README.md"]); + + expect( + flattenFileTree({ + nodes: tree, + expanded: new Set(), + }).map((item) => item.node.path), + ).toEqual(["src", "package.json", "README.md"]); + }); + + it("includes matching descendants and their ancestors during search", () => { + const tree = buildFileTree(entries); + + expect( + flattenFileTree({ + nodes: tree, + expanded: new Set(), + searchQuery: "app", + }).map((item) => item.node.path), + ).toEqual(["src", "src/components", "src/components/App.tsx"]); + }); + + it("supports fuzzy, whitespace-separated path queries", () => { + const tree = buildFileTree([ + { + kind: "file", + path: ".plans/19-version-control-phase-1-vcs-driver-foundation.md", + }, + { + kind: "file", + path: ".repos/alchemy-effect/examples/aws-lambda/src/JobNotifications.ts", + }, + { kind: "directory", path: "apps/web/src/components/chat" }, + { kind: "file", path: "apps/web/src/components/chat/ChatHeader.test.ts" }, + { kind: "file", path: "apps/web/src/components/chat/ChatHeader.tsx" }, + { kind: "file", path: "apps/web/src/components/chat/Composer.tsx" }, + ]); + + const expectedPaths = [ + "apps", + "apps/web", + "apps/web/src", + "apps/web/src/components", + "apps/web/src/components/chat", + "apps/web/src/components/chat/ChatHeader.test.ts", + "apps/web/src/components/chat/ChatHeader.tsx", + ]; + + for (const searchQuery of ["chat hea", "cht hdr"]) { + expect( + flattenFileTree({ + nodes: tree, + expanded: new Set(), + searchQuery, + }).map((item) => item.node.path), + ).toEqual(expectedPaths); + } + }); + + it("expands top-level directories by default", () => { + const tree = buildFileTree(entries); + + expect([...defaultExpandedTreePaths(tree)]).toEqual(["src"]); + }); +}); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts new file mode 100644 index 00000000000..28b5822aaa0 --- /dev/null +++ b/apps/mobile/src/features/files/fileTree.ts @@ -0,0 +1,220 @@ +import type { ProjectEntry } from "@t3tools/contracts"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; + +export interface FileTreeNode { + readonly path: string; + readonly name: string; + readonly kind: ProjectEntry["kind"]; + readonly children: ReadonlyArray; + readonly searchSegments: ReadonlyArray; + readonly searchWords: ReadonlyArray; +} + +export interface VisibleFileTreeNode { + readonly node: FileTreeNode; + readonly depth: number; +} + +interface MutableFileTreeNode { + path: string; + name: string; + kind: ProjectEntry["kind"]; + children: Map; +} + +function createMutableNode( + path: string, + name: string, + kind: ProjectEntry["kind"], +): MutableFileTreeNode { + return { + path, + name, + kind, + children: new Map(), + }; +} + +function splitSearchWords(value: string): ReadonlyArray { + return value + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => word.toLowerCase()); +} + +function buildNodeSearchTerms(path: string): { + readonly segments: ReadonlyArray; + readonly words: ReadonlyArray; +} { + const segments: string[] = []; + const words: string[] = []; + + for (const segment of path.split("/")) { + if (!segment) { + continue; + } + segments.push(segment.toLowerCase()); + words.push(...splitSearchWords(segment)); + } + + return { segments, words }; +} + +function freezeNode(node: MutableFileTreeNode): FileTreeNode { + const searchTerms = buildNodeSearchTerms(node.path); + return { + path: node.path, + name: node.name, + kind: node.kind, + children: [...node.children.values()].sort(compareNodes).map(freezeNode), + searchSegments: searchTerms.segments, + searchWords: searchTerms.words, + }; +} + +function compareNodes( + left: Pick, + right: Pick, +): number { + if (left.kind !== right.kind) { + return left.kind === "directory" ? -1 : 1; + } + return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }); +} + +export function buildFileTree(entries: ReadonlyArray): ReadonlyArray { + const root = createMutableNode("", "", "directory"); + + for (const entry of entries) { + const parts = entry.path.split("/").filter(Boolean); + if (parts.length === 0) { + continue; + } + + let current = root; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (!part) { + continue; + } + + const path = parts.slice(0, index + 1).join("/"); + const isLeaf = index === parts.length - 1; + const kind = isLeaf ? entry.kind : "directory"; + let child = current.children.get(part); + if (!child) { + child = createMutableNode(path, part, kind); + current.children.set(part, child); + } else if (isLeaf) { + child.kind = entry.kind; + } + current = child; + } + } + + return [...root.children.values()].sort(compareNodes).map(freezeNode); +} + +export function countFileNodes(nodes: ReadonlyArray): number { + let count = 0; + for (const node of nodes) { + if (node.kind === "file") { + count += 1; + } else { + count += countFileNodes(node.children); + } + } + return count; +} + +export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { + const expanded = new Set(); + for (const node of nodes) { + if (node.kind === "directory") { + expanded.add(node.path); + } + } + return expanded; +} + +function valueMatchesSearchToken(value: string, token: string, fuzzy: boolean): boolean { + return ( + scoreQueryMatch({ + value, + query: token, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + ...(fuzzy ? { fuzzyBase: 100 } : {}), + boundaryMarkers: ["/", "-", "_", "."], + }) !== null + ); +} + +function nodeMatchesSearch(node: FileTreeNode, tokens: ReadonlyArray): boolean { + return tokens.every( + (token) => + node.searchSegments.some((segment) => valueMatchesSearchToken(segment, token, false)) || + node.searchWords.some((word) => valueMatchesSearchToken(word, token, true)), + ); +} + +function flattenNode( + output: VisibleFileTreeNode[], + node: FileTreeNode, + depth: number, + expanded: ReadonlySet, + searchTokens: ReadonlyArray, +): boolean { + const isSearching = searchTokens.length > 0; + const matches = isSearching && nodeMatchesSearch(node, searchTokens); + let descendantMatches = false; + const childOutput: VisibleFileTreeNode[] = []; + + if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) { + for (const child of node.children) { + if (flattenNode(childOutput, child, depth + 1, expanded, searchTokens)) { + descendantMatches = true; + } + } + } + + const visible = !isSearching || matches || descendantMatches; + if (!visible) { + return false; + } + + output.push({ node, depth }); + output.push(...childOutput); + return matches || descendantMatches; +} + +export function flattenFileTree(input: { + readonly nodes: ReadonlyArray; + readonly expanded: ReadonlySet; + readonly searchQuery?: string; +}): ReadonlyArray { + const output: VisibleFileTreeNode[] = []; + const normalizedSearch = normalizeSearchQuery(input.searchQuery ?? ""); + const searchTokens = normalizedSearch.split(/[\s/\\._-]+/).filter(Boolean); + for (const node of input.nodes) { + flattenNode(output, node, 0, input.expanded, searchTokens); + } + return output; +} + +export function firstFilePath(nodes: ReadonlyArray): string | null { + for (const node of nodes) { + if (node.kind === "file") { + return node.path; + } + const child = firstFilePath(node.children); + if (child !== null) { + return child; + } + } + return null; +} diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts new file mode 100644 index 00000000000..0e7d478c6bd --- /dev/null +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildNativeSourceRows, + buildNativeSourceTokens, + NATIVE_SOURCE_ROW_HEIGHT, + NATIVE_SOURCE_STYLE, + nativeSourceRowId, +} from "./nativeSourceFileAdapter"; +import { + NATIVE_REVIEW_DIFF_ROW_HEIGHT, + NATIVE_REVIEW_DIFF_STYLE, +} from "../review/nativeReviewDiffAdapter"; + +describe("nativeSourceFileAdapter", () => { + it("uses the same compact code typography as the diff viewer", () => { + expect(NATIVE_SOURCE_ROW_HEIGHT).toBe(NATIVE_REVIEW_DIFF_ROW_HEIGHT); + expect(NATIVE_SOURCE_STYLE).toMatchObject({ + rowHeight: NATIVE_REVIEW_DIFF_STYLE.rowHeight, + gutterWidth: NATIVE_REVIEW_DIFF_STYLE.gutterWidth, + codePadding: NATIVE_REVIEW_DIFF_STYLE.codePadding, + textVerticalInset: NATIVE_REVIEW_DIFF_STYLE.textVerticalInset, + codeFontSize: NATIVE_REVIEW_DIFF_STYLE.codeFontSize, + codeFontWeight: NATIVE_REVIEW_DIFF_STYLE.codeFontWeight, + lineNumberFontSize: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontSize, + lineNumberFontWeight: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontWeight, + }); + }); + + it("maps plain source lines onto context rows with stable line numbers", () => { + expect(buildNativeSourceRows(["const value = 1;", "\treturn value;"])).toEqual([ + { + kind: "line", + id: nativeSourceRowId(0), + fileId: "source-file", + content: "const value = 1;", + change: "context", + newLineNumber: 1, + }, + { + kind: "line", + id: nativeSourceRowId(1), + fileId: "source-file", + content: " return value;", + change: "context", + newLineNumber: 2, + }, + ]); + }); + + it("maps cached source tokens to the same row identifiers", () => { + expect( + buildNativeSourceTokens([ + [{ content: "const", color: "#ff0000", fontStyle: 2 }], + [{ content: "\tvalue", color: null, fontStyle: null }], + ]), + ).toEqual({ + [nativeSourceRowId(0)]: [{ content: "const", color: "#ff0000", fontStyle: 2 }], + [nativeSourceRowId(1)]: [{ content: " value", color: null, fontStyle: null }], + }); + }); + + it("clears native tokens while highlighting is unavailable", () => { + expect(buildNativeSourceTokens(null)).toEqual({}); + }); +}); diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts new file mode 100644 index 00000000000..9bb341e2909 --- /dev/null +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -0,0 +1,67 @@ +import type { + NativeReviewDiffRow, + NativeReviewDiffStyle, + NativeReviewDiffToken, +} from "../diffs/nativeReviewDiffSurface"; +import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import type { SourceHighlightTokens } from "./sourceHighlightingState"; + +export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; +export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; + +export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = { + rowHeight: NATIVE_SOURCE_ROW_HEIGHT, + contentWidth: NATIVE_SOURCE_CONTENT_WIDTH, + changeBarWidth: 0, + gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, + codePadding: MOBILE_CODE_SURFACE.codePadding, + textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, + codeFontSize: MOBILE_CODE_SURFACE.fontSize, + codeFontWeight: "regular", + lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, + lineNumberFontWeight: "regular", + emptyStateFontSize: MOBILE_TYPOGRAPHY.label.fontSize, + emptyStateFontWeight: "medium", +}; + +const SOURCE_FILE_ID = "source-file"; + +function expandTabs(value: string): string { + return value.replace(/\t/g, " "); +} + +export function nativeSourceRowId(index: number): string { + return `source-line:${index}`; +} + +export function buildNativeSourceRows( + lines: ReadonlyArray, +): ReadonlyArray { + return lines.map((line, index) => ({ + kind: "line", + id: nativeSourceRowId(index), + fileId: SOURCE_FILE_ID, + content: expandTabs(line), + change: "context", + newLineNumber: index + 1, + })); +} + +export function buildNativeSourceTokens( + tokenLines: SourceHighlightTokens | null, +): Readonly>> { + if (tokenLines === null) { + return {}; + } + + return Object.fromEntries( + tokenLines.map((tokens, index) => [ + nativeSourceRowId(index), + tokens.map((token) => ({ + content: expandTabs(token.content), + color: token.color, + fontStyle: token.fontStyle, + })), + ]), + ); +} diff --git a/apps/mobile/src/features/files/sourceHighlightingState.test.ts b/apps/mobile/src/features/files/sourceHighlightingState.test.ts new file mode 100644 index 00000000000..6c4c00e1663 --- /dev/null +++ b/apps/mobile/src/features/files/sourceHighlightingState.test.ts @@ -0,0 +1,123 @@ +import { AtomRegistry } from "effect/unstable/reactivity"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createSourceHighlightAtomFamily, + type SourceHighlightTokens, +} from "./sourceHighlightingState"; + +const highlightedTokens: SourceHighlightTokens = [ + [{ content: "const", color: "#0000ff", fontStyle: null }], +]; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("sourceHighlightingState", () => { + it("reuses completed highlighting across equivalent route remounts", async () => { + const highlight = vi.fn(async () => highlightedTokens); + const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight, idleTtlMs: 1_000 }); + const registry = AtomRegistry.make({ timeoutResolution: 1 }); + const input = { + path: "src/example.ts", + contents: "const value = 1;", + theme: "light" as const, + }; + const firstAtom = sourceHighlightAtom(input); + const firstUnmount = registry.mount(firstAtom); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(firstAtom))).toBe(true); + }); + firstUnmount(); + + const remountedAtom = sourceHighlightAtom({ ...input }); + const secondUnmount = registry.mount(remountedAtom); + + expect(remountedAtom).toBe(firstAtom); + expect(AsyncResult.isSuccess(registry.get(remountedAtom))).toBe(true); + expect(highlight).toHaveBeenCalledTimes(1); + + secondUnmount(); + registry.dispose(); + }); + + it("does not reuse highlighting when the source contents change", async () => { + const highlight = vi.fn(async () => highlightedTokens); + const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); + const registry = AtomRegistry.make(); + const firstAtom = sourceHighlightAtom({ + path: "src/example.ts", + contents: "const value = 1;", + theme: "light", + }); + const secondAtom = sourceHighlightAtom({ + path: "src/example.ts", + contents: "const value = 2;", + theme: "light", + }); + const firstUnmount = registry.mount(firstAtom); + const secondUnmount = registry.mount(secondAtom); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(firstAtom))).toBe(true); + expect(AsyncResult.isSuccess(registry.get(secondAtom))).toBe(true); + }); + expect(secondAtom).not.toBe(firstAtom); + expect(highlight).toHaveBeenCalledTimes(2); + + firstUnmount(); + secondUnmount(); + registry.dispose(); + }); + + it("recomputes highlighting after the idle cache entry expires", async () => { + const highlight = vi.fn(async () => highlightedTokens); + const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight, idleTtlMs: 5 }); + const registry = AtomRegistry.make({ timeoutResolution: 1 }); + const atom = sourceHighlightAtom({ + path: "src/example.ts", + contents: "const value = 1;", + theme: "light", + }); + const firstUnmount = registry.mount(atom); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); + }); + firstUnmount(); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const secondUnmount = registry.mount(atom); + await vi.waitFor(() => { + expect(highlight).toHaveBeenCalledTimes(2); + expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); + }); + + secondUnmount(); + registry.dispose(); + }); + + it("exposes highlighter errors as a failed async result", async () => { + const highlight = vi.fn(async () => { + throw new Error("highlight failed"); + }); + const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); + const registry = AtomRegistry.make(); + const atom = sourceHighlightAtom({ + path: "src/example.ts", + contents: "const value = 1;", + theme: "light", + }); + const unmount = registry.mount(atom); + + await vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); + }); + + unmount(); + registry.dispose(); + }); +}); diff --git a/apps/mobile/src/features/files/sourceHighlightingState.ts b/apps/mobile/src/features/files/sourceHighlightingState.ts new file mode 100644 index 00000000000..43363115bc8 --- /dev/null +++ b/apps/mobile/src/features/files/sourceHighlightingState.ts @@ -0,0 +1,50 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { + highlightSourceFile, + type ReviewDiffTheme, + type ReviewHighlightedToken, +} from "../review/shikiReviewHighlighter"; + +const SOURCE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000; + +export interface SourceHighlightInput { + readonly path: string; + readonly contents: string; + readonly theme: ReviewDiffTheme; +} + +export type SourceHighlightTokens = ReadonlyArray>; + +type SourceHighlighter = (input: SourceHighlightInput) => Promise; + +class SourceHighlightCacheKey extends Data.Class {} + +class SourceHighlightError extends Data.TaggedError("SourceHighlightError")<{ + readonly cause: unknown; +}> {} + +export function createSourceHighlightAtomFamily(options?: { + readonly highlight?: SourceHighlighter; + readonly idleTtlMs?: number; +}) { + const highlight = options?.highlight ?? highlightSourceFile; + const idleTtlMs = options?.idleTtlMs ?? SOURCE_HIGHLIGHT_IDLE_TTL_MS; + const family = Atom.family((request: SourceHighlightCacheKey) => + Atom.make( + Effect.tryPromise({ + try: () => highlight(request), + catch: (cause) => new SourceHighlightError({ cause }), + }), + ).pipe( + Atom.setIdleTTL(idleTtlMs), + Atom.withLabel(`mobile:source-highlight:${request.theme}:${request.path}`), + ), + ); + + return (input: SourceHighlightInput) => family(new SourceHighlightCacheKey(input)); +} + +export const sourceHighlightAtom = createSourceHighlightAtomFamily(); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts new file mode 100644 index 00000000000..4acb67361a8 --- /dev/null +++ b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts @@ -0,0 +1,64 @@ +import { AtomRegistry } from "effect/unstable/reactivity"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; + +describe("workspaceFileImageAtom", () => { + it("reuses a prefetched image across route remounts", async () => { + const prefetch = vi.fn(async () => true); + const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch }); + const registry = AtomRegistry.make({ timeoutResolution: 1 }); + const first = imageAtom("https://example.test/image.png"); + const firstUnmount = registry.mount(first); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); + }); + firstUnmount(); + + const remounted = imageAtom("https://example.test/image.png"); + const secondUnmount = registry.mount(remounted); + + expect(remounted).toBe(first); + expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true); + expect(prefetch).toHaveBeenCalledTimes(1); + + secondUnmount(); + registry.dispose(); + }); + + it("prefetches different asset URLs independently", async () => { + const prefetch = vi.fn(async () => true); + const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch }); + const registry = AtomRegistry.make(); + const first = imageAtom("https://example.test/first.png"); + const second = imageAtom("https://example.test/second.png"); + const firstUnmount = registry.mount(first); + const secondUnmount = registry.mount(second); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); + expect(AsyncResult.isSuccess(registry.get(second))).toBe(true); + }); + expect(prefetch).toHaveBeenCalledTimes(2); + + firstUnmount(); + secondUnmount(); + registry.dispose(); + }); + + it("exposes prefetch failures", async () => { + const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); + const registry = AtomRegistry.make(); + const atom = imageAtom("https://example.test/missing.png"); + const unmount = registry.mount(atom); + + await vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); + }); + + unmount(); + registry.dispose(); + }); +}); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts new file mode 100644 index 00000000000..3f58f65b46c --- /dev/null +++ b/apps/mobile/src/features/files/workspace-file-image-cache.ts @@ -0,0 +1,48 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; + +type ImagePrefetch = (uri: string) => Promise; + +class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} + +export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ + readonly cause?: unknown; + readonly uri: string; +}> {} + +async function prefetchWithNativeImage(uri: string): Promise { + const { Image } = await import("react-native"); + return Image.prefetch(uri); +} + +export function createWorkspaceFileImageAtomFamily(options?: { + readonly idleTtlMs?: number; + readonly prefetch?: ImagePrefetch; +}) { + const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; + const prefetch = options?.prefetch ?? prefetchWithNativeImage; + const family = Atom.family((key: WorkspaceImageCacheKey) => + Atom.make( + Effect.tryPromise({ + try: async () => { + const cached = await prefetch(key.uri); + if (!cached) { + throw new WorkspaceImagePrefetchError({ uri: key.uri }); + } + return key.uri; + }, + catch: (cause) => + cause instanceof WorkspaceImagePrefetchError + ? cause + : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), + }), + ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), + ); + + return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); +} + +export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily(); diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts new file mode 100644 index 00000000000..70ea3e43582 --- /dev/null +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -0,0 +1,31 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { useAssetUrl } from "../../state/assets"; +import { resolveWorkspaceFilePath } from "./filePath"; + +export function useWorkspaceFileAssetUrl(props: { + readonly cwd: string | null; + readonly environmentId: EnvironmentId | null; + readonly relativePath: string | null; + readonly threadId: ThreadId | null; +}) { + const absolutePath = useMemo( + () => + props.cwd !== null && props.relativePath !== null + ? resolveWorkspaceFilePath(props.cwd, props.relativePath) + : null, + [props.cwd, props.relativePath], + ); + + return useAssetUrl( + props.environmentId, + absolutePath !== null && props.threadId !== null + ? { + _tag: "workspace-file", + threadId: props.threadId, + path: absolutePath, + } + : null, + ); +} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx new file mode 100644 index 00000000000..9757d5fbf91 --- /dev/null +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -0,0 +1,245 @@ +import type { + EnvironmentId, + SidebarProjectGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import { + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +} from "@t3tools/contracts"; +import { Stack } from "expo-router"; +import { Text as RNText, View } from "react-native"; + +import { useThemeColor } from "../../lib/useThemeColor"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import type { HomeProjectSortOrder } from "./homeThreadList"; + +export interface HomeHeaderEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +const PROJECT_SORT_OPTIONS: ReadonlyArray<{ + readonly value: HomeProjectSortOrder; + readonly label: string; +}> = [ + { value: "updated_at", label: "Last user message" }, + { value: "created_at", label: "Created at" }, +]; + +const THREAD_SORT_OPTIONS: ReadonlyArray<{ + readonly value: SidebarThreadSortOrder; + readonly label: string; +}> = [ + { value: "updated_at", label: "Last user message" }, + { value: "created_at", label: "Created at" }, +]; + +const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ + readonly value: SidebarProjectGroupingMode; + readonly label: string; + readonly subtitle: string; +}> = [ + { + value: "repository", + label: "Group by repository", + subtitle: "Combine matching repositories across environments", + }, + { + value: "repository_path", + label: "Group by repository path", + subtitle: "Combine only matching paths within a repository", + }, + { + value: "separate", + label: "Keep separate", + subtitle: "Show every project path separately", + }, +]; + +export function HomeHeader(props: { + readonly environments: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly onSearchQueryChange: (query: string) => void; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; + readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; + readonly onOpenSettings: () => void; + readonly onStartNewTask: () => void; +}) { + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const subtleColor = useThemeColor("--color-subtle"); + const hasCustomListOptions = + props.selectedEnvironmentId !== null || + props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER || + props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || + props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE; + + return ( + <> + { + props.onSearchQueryChange(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + allowToolbarIntegration: true, + }, + }} + /> + + + + + + T3 Code + + + + Alpha + + + + + + + + + + Environment + props.onEnvironmentChange(null)} + subtitle="Show threads from every environment" + > + All environments + + {props.environments.map((environment) => ( + props.onEnvironmentChange(environment.environmentId)} + > + {environment.label} + + ))} + + + + Sort projects + {PROJECT_SORT_OPTIONS.map((option) => ( + props.onProjectSortOrderChange(option.value)} + > + {option.label} + + ))} + + + + Sort threads + {THREAD_SORT_OPTIONS.map((option) => ( + props.onThreadSortOrderChange(option.value)} + > + {option.label} + + ))} + + + + Group projects + {PROJECT_GROUPING_OPTIONS.map((option) => ( + props.onProjectGroupingModeChange(option.value)} + subtitle={option.subtitle} + > + {option.label} + + ))} + + + + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index cdae41668a0..7ee5660edf1 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,55 +1,65 @@ +import { + type EnvironmentProject, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; import type { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - VcsStatusState, -} from "@t3tools/client-runtime"; + EnvironmentId, + SidebarProjectGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import * as Haptics from "expo-haptics"; import { SymbolView } from "expo-symbols"; -import { useCallback, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import ReanimatedSwipeable, { + type SwipeableMethods, +} from "react-native-gesture-handler/ReanimatedSwipeable"; +import Animated, { + Easing, + LinearTransition, + type ExitAnimationsValues, + withDelay, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; import { relativeTime } from "../../lib/time"; -import type { RemoteCatalogState } from "../../state/use-remote-catalog"; -import { useVcsStatus } from "../../state/use-vcs-status"; import { threadStatusTone } from "../threads/threadPresentation"; +import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; +import { + THREAD_SWIPE_ACTIONS_WIDTH, + THREAD_SWIPE_SPRING, + ThreadSwipeActions, +} from "./thread-swipe-actions"; /* ─── Types ──────────────────────────────────────────────────────────── */ interface HomeScreenProps { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly catalogState: RemoteCatalogState; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly>; readonly searchQuery: string; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onAddConnection: () => void; - readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; + readonly onOpenEnvironments: () => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; } -interface ProjectGroup { - readonly key: string; - readonly project: EnvironmentScopedProjectShell; - readonly threads: ReadonlyArray; -} - -const projectGroupActivityOrder = Order.mapInput( - Order.Struct({ - activityAt: Order.flip(Order.Number), - }), - (group: ProjectGroup) => ({ - activityAt: new Date(group.threads[0]!.updatedAt ?? group.threads[0]!.createdAt).getTime(), - }), -); - /* ─── Status indicator colors ────────────────────────────────────────── */ -function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } { +function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { switch (thread.session?.status) { case "running": return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; @@ -65,13 +75,40 @@ function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: s } const COLLAPSED_THREAD_LIMIT = 6; +const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic)); + +function threadRowExit(values: ExitAnimationsValues) { + "worklet"; + + return { + initialValues: { + height: values.currentHeight, + opacity: 1, + originX: values.currentOriginX, + }, + animations: { + height: withDelay( + 90, + withTiming(0, { + duration: 170, + easing: Easing.inOut(Easing.cubic), + }), + ), + opacity: withDelay(80, withTiming(0, { duration: 100 })), + originX: withTiming(values.currentOriginX - values.windowWidth, { + duration: 190, + easing: Easing.out(Easing.cubic), + }), + }, + }; +} function deriveEmptyState(props: { - readonly catalogState: RemoteCatalogState; + readonly catalogState: WorkspaceState; readonly projectCount: number; }): { readonly title: string; readonly detail: string; readonly loading: boolean } { const { catalogState } = props; - if (catalogState.isLoadingSavedConnections) { + if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", @@ -79,7 +116,7 @@ function deriveEmptyState(props: { }; } - if (!catalogState.hasSavedConnections) { + if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment to load projects and start coding sessions.", @@ -87,7 +124,12 @@ function deriveEmptyState(props: { }; } - if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error") && + !catalogState.hasLoadedShellSnapshot + ) { return { title: "Environment unavailable", detail: @@ -127,10 +169,9 @@ function deriveEmptyState(props: { /* ─── Project group header ───────────────────────────────────────────── */ function ProjectGroupLabel(props: { - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; + readonly title: string; readonly totalThreadCount: number; - readonly httpBaseUrl: string | null; - readonly bearerToken: string | null; readonly isExpanded: boolean; readonly onToggleExpand: () => void; }) { @@ -139,24 +180,23 @@ function ProjectGroupLabel(props: { return ( - {props.project.title} + {props.title} {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} @@ -167,134 +207,239 @@ function ProjectGroupLabel(props: { ); } -/* ─── Git summary line ──────────────────────────────────────────────── */ - -function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray { - if (!gitStatus.data) return []; - const { data } = gitStatus; - const parts: string[] = []; - if (data.hasWorkingTreeChanges) { - parts.push(`${data.workingTree.files.length} changed`); - } - if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`); - if (data.behindCount > 0) parts.push(`${data.behindCount} behind`); - if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`); - return parts; -} - /* ─── Thread row ─────────────────────────────────────────────────────── */ function ThreadRow(props: { - readonly thread: EnvironmentScopedThreadShell; - readonly projectCwd: string | null; + readonly thread: EnvironmentThreadShell; + readonly environmentLabel: string | null; readonly onPress: () => void; + readonly onArchive: () => void; + readonly onDelete: () => void; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly isLast: boolean; }) { + const swipeableRef = useRef(null); + const fullSwipeArmedRef = useRef(false); + const { width: windowWidth } = useWindowDimensions(); const separatorColor = useThemeColor("--color-separator"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); + const cardColor = useThemeColor("--color-card"); + const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); const { bg, fg } = statusColors(props.thread); const tone = threadStatusTone(props.thread); - const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); + const timestamp = relativeTime( + props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt, + ); const branch = props.thread.branch; - - // Subscribe to live git status — only when thread has a branch set. - // Threads sharing the same cwd share one WS subscription via ref-counting. - const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null; - const gitStatus = useVcsStatus({ - environmentId: cwd ? props.thread.environmentId : null, - cwd, - }); - const gitParts = gitSummaryParts(gitStatus); + const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => + Boolean(part), + ); + const handleFullSwipeArmedChange = useCallback((armed: boolean) => { + if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + fullSwipeArmedRef.current = armed; + }, []); return ( - ({ opacity: pressed ? 0.7 : 1 })}> - { + fullSwipeArmedRef.current = false; + if (swipeableRef.current) { + props.onSwipeableClose(swipeableRef.current); + } + }} + onSwipeableOpenStartDrag={() => { + if (swipeableRef.current) { + props.onSwipeableWillOpen(swipeableRef.current); + } + }} + onSwipeableWillOpen={() => { + const methods = swipeableRef.current; + if (!methods) { + return; + } + + props.onSwipeableWillOpen(methods); + if (fullSwipeArmedRef.current) { + fullSwipeArmedRef.current = false; + methods.close(); + props.onDelete(); + } + }} + overshootFriction={1} + overshootRight + renderRightActions={(_progress, translation, methods) => ( + + )} + rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + > + { + swipeableRef.current?.close(); + props.onPress(); }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > - {/* Git status indicator */} - - - - {/* Content */} - - {/* Title + Status + Timestamp */} - - - {props.thread.title} - - - - - {tone.label} - - - - {timestamp} - - + + - {/* Branch + git info */} - {branch ? ( - - + + - {branch} + {props.thread.title} - {gitParts.length > 0 ? ( - - {" · " + gitParts.join(" · ")} + + + + {tone.label} + + + + {timestamp} - ) : null} + - ) : null} + + {subtitleParts.length > 0 ? ( + + + + {subtitleParts.join(" · ")} + + + ) : null} + - - + + ); } /* ─── Main screen ────────────────────────────────────────────────────── */ +function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string { + if (props.catalogState.networkStatus === "offline") { + return "You are offline"; + } + const connectingEnvironments = props.catalogState.connectingEnvironments; + if (connectingEnvironments.length === 1) { + return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`; + } + if (connectingEnvironments.length > 1) { + return `Reconnecting ${connectingEnvironments.length} environments`; + } + return "Not connected"; +} + +function StaleCatalogStatusPill(props: { + readonly catalogState: WorkspaceState; + readonly onPress: () => void; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + const label = staleCatalogPillLabel(props); + const isReconnecting = props.catalogState.connectingEnvironments.length > 0; + + return ( + + {isReconnecting ? ( + + ) : ( + + )} + + {label} + + + ); +} + export function HomeScreen(props: HomeScreenProps) { const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); + const openSwipeableRef = useRef(null); + const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); const toggleExpanded = useCallback((key: string) => { @@ -306,122 +451,170 @@ export function HomeScreen(props: HomeScreenProps) { }); }, []); - /* Build project title lookup for search */ - const projectTitleByKey = useMemo(() => { - const map = new Map(); - for (const p of props.projects) { - map.set(scopedProjectKey(p.environmentId, p.id), p.title); - } - return map; - }, [props.projects]); - - /* Filter threads by search query */ - const filteredThreads = useMemo(() => { - const q = props.searchQuery.trim().toLowerCase(); - if (!q) return props.threads; - return props.threads.filter((t) => { - if (t.title.toLowerCase().includes(q)) return true; - const key = scopedProjectKey(t.environmentId, t.projectId); - return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false; - }); - }, [props.threads, props.searchQuery, projectTitleByKey]); - - /* Group filtered threads by project */ - const projectGroups = useMemo>(() => { - const byProject = new Map(); - for (const thread of filteredThreads) { - const key = scopedProjectKey(thread.environmentId, thread.projectId); - const existing = byProject.get(key); - if (existing) existing.push(thread); - else byProject.set(key, [thread]); + const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current !== methods) { + openSwipeableRef.current?.close(); + openSwipeableRef.current = methods; } + }, []); - const groups: ProjectGroup[] = []; - for (const project of props.projects) { - const key = scopedProjectKey(project.environmentId, project.id); - const threads = byProject.get(key); - if (threads && threads.length > 0) { - groups.push({ key, project, threads }); - } + const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current === methods) { + openSwipeableRef.current = null; } + }, []); - return Arr.sort(groups, projectGroupActivityOrder); - }, [props.projects, filteredThreads]); + const projectGroups = useMemo( + () => + buildHomeThreadGroups({ + projects: props.projects, + threads: props.threads, + environmentId: props.selectedEnvironmentId, + searchQuery: props.searchQuery, + projectSortOrder: props.projectSortOrder, + threadSortOrder: props.threadSortOrder, + projectGroupingMode: props.projectGroupingMode, + }), + [ + props.projectGroupingMode, + props.projects, + props.projectSortOrder, + props.searchQuery, + props.selectedEnvironmentId, + props.threadSortOrder, + props.threads, + ], + ); /* Empty states */ - const hasAnyThreads = props.threads.length > 0; - const hasResults = filteredThreads.length > 0; + const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); + const hasResults = projectGroups.length > 0; + const selectedEnvironmentLabel = + props.selectedEnvironmentId === null + ? null + : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? + "this environment"); + const hasSearchQuery = props.searchQuery.trim().length > 0; + const shouldShowConnectionStatus = + props.catalogState.networkStatus === "offline" || + props.catalogState.hasConnectingEnvironment || + (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment); const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); return ( - - {!hasAnyThreads ? ( - + + openSwipeableRef.current?.close()} + className="flex-1" + contentContainerStyle={{ + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 24, + gap: 20, + }} + > + {!hasAnyThreads ? ( + + + {emptyState.loading ? ( + + + + ) : null} + + ) : !hasResults && hasSearchQuery ? ( + + ) : !hasResults && selectedEnvironmentLabel ? ( - {emptyState.loading ? ( - - - - ) : null} - - ) : !hasResults ? ( - - ) : ( - projectGroups.map((group) => { - const connection = props.savedConnectionsById[group.project.environmentId]; - const isExpanded = expandedProjects.has(group.key); - const visibleThreads = isExpanded - ? group.threads - : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); - - return ( - - toggleExpanded(group.key)} - /> - + ) : ( + projectGroups.map((group) => { + const isExpanded = expandedProjects.has(group.key); + const visibleThreads = isExpanded + ? group.threads + : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); + + return ( + - {visibleThreads.map((thread, i) => ( - props.onSelectThread(thread)} - isLast={i === visibleThreads.length - 1} - /> - ))} - - - ); - }) - )} - + toggleExpanded(group.key)} + project={group.representative} + title={group.title} + totalThreadCount={group.threads.length} + /> + + {visibleThreads.map((thread, i) => { + const threadKey = `${thread.environmentId}:${thread.id}`; + return ( + + props.onArchiveThread(thread)} + onDelete={() => props.onDeleteThread(thread)} + onPress={() => props.onSelectThread(thread)} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + /> + + ); + })} + + + ); + }) + )} + + {shouldShowConnectionStatus ? ( + + + + ) : null} + ); } diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts new file mode 100644 index 00000000000..b68f55167d4 --- /dev/null +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -0,0 +1,224 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildHomeThreadGroups } from "./homeThreadList"; + +function makeProject( + input: Partial & Pick, +): EnvironmentProject { + return { + workspaceRoot: `/workspaces/${input.id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + ...input, + }; +} + +function makeThread( + input: Partial & + Pick, +): EnvironmentThreadShell { + return { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + session: null, + goal: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +function buildGroups( + projects: ReadonlyArray, + threads: ReadonlyArray, + overrides: Partial[0]> = {}, +) { + return buildHomeThreadGroups({ + projects, + threads, + environmentId: null, + searchQuery: "", + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + projectGroupingMode: "repository", + ...overrides, + }); +} + +describe("buildHomeThreadGroups", () => { + it("sorts the newest thread first regardless of snapshot order", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const threads = [ + makeThread({ + environmentId, + id: ThreadId.make("thread-old"), + projectId: project.id, + title: "Older thread", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("thread-new"), + projectId: project.id, + title: "Newer thread", + updatedAt: "2026-06-03T00:00:00.000Z", + }), + ]; + + expect(buildGroups([project], threads)[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-new", + "thread-old", + ]); + }); + + it("supports independent project and thread creation-time sorting", () => { + const environmentId = EnvironmentId.make("environment-1"); + const olderProject = makeProject({ + environmentId, + id: ProjectId.make("project-older"), + title: "Older project", + }); + const newerProject = makeProject({ + environmentId, + id: ProjectId.make("project-newer"), + title: "Newer project", + }); + const threads = [ + makeThread({ + environmentId, + id: ThreadId.make("old-created"), + projectId: olderProject.id, + title: "Updated recently", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("new-created"), + projectId: olderProject.id, + title: "Created recently", + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("newest-project-thread"), + projectId: newerProject.id, + title: "Newest project", + createdAt: "2026-06-06T00:00:00.000Z", + }), + ]; + + const groups = buildGroups([olderProject, newerProject], threads, { + projectSortOrder: "created_at", + threadSortOrder: "created_at", + projectGroupingMode: "separate", + }); + + expect(groups.map((group) => group.representative.id)).toEqual([ + "project-newer", + "project-older", + ]); + expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["new-created", "old-created"]); + }); + + it("filters both projects and threads to one environment", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const projects = [ + makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-local"), + title: "Local", + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote"), + title: "Remote", + }), + ]; + const threads = projects.map((project) => + makeThread({ + environmentId: project.environmentId, + id: ThreadId.make(`thread-${project.id}`), + projectId: project.id, + title: project.title, + }), + ); + + const groups = buildGroups(projects, threads, { environmentId: remoteEnvironmentId }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.representative.environmentId).toBe(remoteEnvironmentId); + expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); + }); + + it("matches web repository, repository-path, and separate grouping modes", () => { + const environmentId = EnvironmentId.make("environment-1"); + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + provider: "github", + owner: "t3tools", + name: "t3code", + displayName: "T3 Code", + rootPath: "/workspaces/t3code", + }; + const projects = [ + makeProject({ + environmentId, + id: ProjectId.make("project-web"), + title: "Web", + workspaceRoot: "/workspaces/t3code/apps/web", + repositoryIdentity, + }), + makeProject({ + environmentId, + id: ProjectId.make("project-mobile"), + title: "Mobile", + workspaceRoot: "/workspaces/t3code/apps/mobile", + repositoryIdentity, + }), + ]; + const threads = projects.map((project) => + makeThread({ + environmentId, + id: ThreadId.make(`thread-${project.id}`), + projectId: project.id, + title: project.title, + }), + ); + + expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); + expect(buildGroups(projects, threads, { projectGroupingMode: "repository_path" })).toHaveLength( + 2, + ); + expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2); + }); +}); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts new file mode 100644 index 00000000000..9f09e894c20 --- /dev/null +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -0,0 +1,140 @@ +import { + deriveLogicalProjectKey, + deriveProjectGroupLabel, +} from "@t3tools/client-runtime/state/project-grouping"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort"; +import type { + EnvironmentId, + SidebarProjectGroupingMode, + SidebarProjectSortOrder, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; + +export type HomeProjectSortOrder = Exclude; + +export interface HomeThreadGroup { + readonly key: string; + readonly title: string; + readonly representative: EnvironmentProject; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +} + +interface MutableHomeThreadGroup { + readonly key: string; + readonly projects: EnvironmentProject[]; + readonly threads: EnvironmentThreadShell[]; +} + +function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { + return group.threads.reduce( + (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), + Number.NEGATIVE_INFINITY, + ); +} + +export function buildHomeThreadGroups(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly searchQuery: string; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; +}): ReadonlyArray { + const groups = new Map(); + const groupKeyByProjectKey = new Map(); + + for (const project of input.projects) { + if (input.environmentId !== null && project.environmentId !== input.environmentId) { + continue; + } + + const groupKey = deriveLogicalProjectKey(project, { + groupingMode: input.projectGroupingMode, + }); + const physicalKey = scopedProjectKey(project.environmentId, project.id); + groupKeyByProjectKey.set(physicalKey, groupKey); + + const existing = groups.get(groupKey); + if (existing) { + existing.projects.push(project); + } else { + groups.set(groupKey, { key: groupKey, projects: [project], threads: [] }); + } + } + + for (const thread of input.threads) { + if (thread.archivedAt !== null) { + continue; + } + if (input.environmentId !== null && thread.environmentId !== input.environmentId) { + continue; + } + + const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId); + const groupKey = groupKeyByProjectKey.get(physicalKey); + if (!groupKey) { + continue; + } + groups.get(groupKey)?.threads.push(thread); + } + + const query = input.searchQuery.trim().toLocaleLowerCase(); + const result: HomeThreadGroup[] = []; + + for (const group of groups.values()) { + const representative = group.projects[0]; + if (!representative || group.threads.length === 0) { + continue; + } + + const title = + group.projects.length > 1 + ? deriveProjectGroupLabel({ representative, members: group.projects }) + : representative.title; + const groupMatches = + query.length === 0 || + title.toLocaleLowerCase().includes(query) || + group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); + const matchingThreads = groupMatches + ? group.threads + : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); + + if (matchingThreads.length === 0) { + continue; + } + + result.push({ + key: group.key, + title, + representative, + projects: group.projects, + threads: sortThreads(matchingThreads, input.threadSortOrder), + }); + } + + return Arr.sort( + result, + Order.mapInput( + Order.Struct({ + timestamp: Order.flip(Order.Number), + title: Order.String, + key: Order.String, + }), + (group: HomeThreadGroup) => ({ + timestamp: groupSortTimestamp(group, input.projectSortOrder), + title: group.title, + key: group.key, + }), + ), + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx new file mode 100644 index 00000000000..dd0e2901bba --- /dev/null +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -0,0 +1,238 @@ +import { SymbolView } from "expo-symbols"; +import type { ComponentProps } from "react"; +import type { ColorValue } from "react-native"; +import { Pressable, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import Animated, { + Extrapolation, + interpolate, + runOnJS, + type SharedValue, + useAnimatedReaction, + useAnimatedStyle, +} from "react-native-reanimated"; + +import { AppText as Text } from "../../components/AppText"; + +const ACTION_ITEM_WIDTH = 50; +const ACTION_CIRCLE_SIZE = 36; +const ACTION_ICON_SIZE = 15; + +export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; +export const THREAD_SWIPE_SPRING = { + damping: 26, + mass: 0.7, + overshootClamping: true, + stiffness: 330, +}; + +function SwipeActionButton(props: { + readonly accessibilityLabel: string; + readonly backgroundColor: string; + readonly entryRange: readonly [number, number]; + readonly fullSwipeThreshold: number; + readonly icon: ComponentProps["name"]; + readonly label: string; + readonly onPress: () => void; + readonly stretchesOnFullSwipe: boolean; + readonly translation: SharedValue; +}) { + const actionStyle = useAnimatedStyle(() => { + const reveal = Math.max(-props.translation.value, 0); + const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); + const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); + const fullSwipeProgress = interpolate( + reveal, + [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20], + [0, 1], + Extrapolation.CLAMP, + ); + + return { + opacity: props.stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), + transform: [ + { + translateX: + interpolate(entryProgress, [0, 1], [22, 0]) - + (props.stretchesOnFullSwipe ? 0 : stretch), + }, + { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) }, + ], + }; + }); + const circleStyle = useAnimatedStyle(() => { + const reveal = Math.max(-props.translation.value, 0); + const stretch = props.stretchesOnFullSwipe + ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) + : 0; + + return { + transform: [{ translateX: -stretch }], + width: ACTION_CIRCLE_SIZE + stretch, + }; + }); + const iconStyle = useAnimatedStyle(() => { + const reveal = Math.max(-props.translation.value, 0); + const stretch = props.stretchesOnFullSwipe + ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) + : 0; + const armedProgress = interpolate( + reveal, + [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], + [0, 1], + Extrapolation.CLAMP, + ); + + return { + transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.5) }], + }; + }); + const labelStyle = useAnimatedStyle(() => { + if (!props.stretchesOnFullSwipe) { + return { opacity: 1 }; + } + + const reveal = Math.max(-props.translation.value, 0); + const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); + return { + opacity: interpolate( + reveal, + [props.fullSwipeThreshold - 24, props.fullSwipeThreshold], + [1, 0], + Extrapolation.CLAMP, + ), + transform: [{ translateX: -stretch * 0.5 }], + }; + }); + + return ( + + ({ + alignItems: "center", + height: "100%", + justifyContent: "center", + opacity: pressed ? 0.72 : 1, + width: "100%", + })} + > + + + + + + + + {props.label} + + + + ); +} + +export function ThreadSwipeActions(props: { + readonly backgroundColor: ColorValue; + readonly fullSwipeThreshold: number; + readonly onDelete: () => void; + readonly onFullSwipeArmedChange: (armed: boolean) => void; + readonly primaryAction: { + readonly accessibilityLabel: string; + readonly icon: ComponentProps["name"]; + readonly label: string; + readonly onPress: () => void; + }; + readonly swipeableMethods: SwipeableMethods; + readonly threadTitle: string; + readonly translation: SharedValue; +}) { + useAnimatedReaction( + () => -props.translation.value >= props.fullSwipeThreshold, + (armed, previous) => { + if (armed !== previous) { + runOnJS(props.onFullSwipeArmedChange)(armed); + } + }, + [props.fullSwipeThreshold, props.onFullSwipeArmedChange], + ); + + return ( + + + { + props.swipeableMethods.close(); + props.onDelete(); + }} + stretchesOnFullSwipe + translation={props.translation} + /> + + ); +} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts new file mode 100644 index 00000000000..cc5d0dd047f --- /dev/null +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -0,0 +1,142 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import * as Cause from "effect/Cause"; +import * as Haptics from "expo-haptics"; +import { useCallback, useRef } from "react"; +import { Alert } from "react-native"; + +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +type ThreadListAction = "archive" | "unarchive" | "delete"; + +function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { + const error = Cause.squash(cause); + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + const verb = + action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted"; + return `The thread could not be ${verb}.`; +} + +function selectionHaptic(): void { + if (process.env.EXPO_OS === "ios") { + void Haptics.selectionAsync(); + } +} + +function actionFailureTitle(action: ThreadListAction): string { + if (action === "archive") return "Could not archive thread"; + if (action === "unarchive") return "Could not unarchive thread"; + return "Could not delete thread"; +} + +function useThreadActionExecutor( + onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, +) { + const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); + const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); + const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const inFlightThreadKeys = useRef(new Set()); + + const executeAction = useCallback( + async (action: ThreadListAction, thread: EnvironmentThreadShell) => { + const key = scopedThreadKey(thread.environmentId, thread.id); + if (inFlightThreadKeys.current.has(key)) { + return; + } + + inFlightThreadKeys.current.add(key); + selectionHaptic(); + try { + const mutation = + action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation; + const result = await mutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); + return; + } + onCompleted?.(action, thread); + } finally { + inFlightThreadKeys.current.delete(key); + } + }, + [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], + ); + + return executeAction; +} + +function useConfirmDeleteThread( + executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, +) { + return useCallback( + (thread: EnvironmentThreadShell) => { + Alert.alert( + "Delete thread?", + `“${thread.title}” will be permanently deleted, including its terminal history.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void executeAction("delete", thread); + }, + }, + ], + ); + }, + [executeAction], + ); +} + +export function useThreadListActions(): { + readonly archiveThread: (thread: EnvironmentThreadShell) => void; + readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; +} { + const executeAction = useThreadActionExecutor(); + + const archiveThread = useCallback( + (thread: EnvironmentThreadShell) => { + void executeAction("archive", thread); + }, + [executeAction], + ); + + const confirmDeleteThread = useConfirmDeleteThread(executeAction); + + return { archiveThread, confirmDeleteThread }; +} + +export function useArchivedThreadListActions( + onCompleted: (thread: EnvironmentThreadShell) => void, +): { + readonly unarchiveThread: (thread: EnvironmentThreadShell) => void; + readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; +} { + const handleCompleted = useCallback( + (_action: ThreadListAction, thread: EnvironmentThreadShell) => { + onCompleted(thread); + }, + [onCompleted], + ); + const executeAction = useThreadActionExecutor(handleCompleted); + const unarchiveThread = useCallback( + (thread: EnvironmentThreadShell) => { + void executeAction("unarchive", thread); + }, + [executeAction], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); + + return { unarchiveThread, confirmDeleteThread }; +} diff --git a/apps/mobile/src/features/observability/mobileTracing.test.ts b/apps/mobile/src/features/observability/mobileTracing.test.ts deleted file mode 100644 index 0b0f83c6971..00000000000 --- a/apps/mobile/src/features/observability/mobileTracing.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; - -import { remoteHttpClientLayer } from "@t3tools/client-runtime"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; - -import { makeMobileTracingLayer } from "./mobileTracing"; - -vi.mock("expo-constants", () => ({ - default: { - expoConfig: { - extra: {}, - }, - }, -})); - -it.effect("exports spans through the scoped mobile OTLP layer", () => { - const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); - const tracingLayer = makeMobileTracingLayer( - { - tracesUrl: "https://api.axiom.test/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - { - appVariant: "test", - serviceVersion: "1.2.3", - }, - ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); - const tracedApplication = Layer.effectDiscard( - Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), - ).pipe(Layer.provide(tracingLayer)); - - return Effect.gen(function* () { - yield* Layer.build(tracedApplication); - - expect(fetchFn).not.toHaveBeenCalled(); - }).pipe( - Effect.scoped, - Effect.andThen( - Effect.sync(() => { - expect(fetchFn).toHaveBeenCalledOnce(); - const [url, init] = fetchFn.mock.calls[0]!; - expect(String(url)).toBe("https://api.axiom.test/v1/traces"); - expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); - expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); - expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); - }), - ), - ); -}); diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts new file mode 100644 index 00000000000..b0deb15be8c --- /dev/null +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; + +import { makeTracingLayer } from "./tracing"; + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + extra: {}, + }, + }, +})); + +it.effect("exports spans through the scoped mobile OTLP layer", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const tracingLayer = makeTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "mobile-traces", + tracesToken: "public-ingest-token", + }, + { + appVariant: "test", + serviceVersion: "1.2.3", + }, + ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); + const tracedApplication = Layer.effectDiscard( + Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), + ).pipe(Layer.provide(tracingLayer)); + + return Effect.gen(function* () { + yield* Layer.build(tracedApplication); + + expect(fetchFn).not.toHaveBeenCalled(); + }).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + const [url, init] = fetchFn.mock.calls[0]!; + expect(String(url)).toBe("https://api.axiom.test/v1/traces"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); + expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); + expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); + }), + ), + ); +}); + +it.effect("does not let OTLP serialization failures alter application effects", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const tracingLayer = makeTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "mobile-traces", + tracesToken: "public-ingest-token", + }, + { + appVariant: "test", + serviceVersion: "1.2.3", + }, + ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); + const failure = { durationNanos: 1n }; + const tracedApplication = Layer.effectDiscard( + Effect.fail(failure).pipe( + Effect.withSpan("mobile.test.failed-span"), + withRelayClientTracing, + Effect.exit, + Effect.flatMap((exit) => { + const reason = exit._tag === "Failure" ? exit.cause.reasons[0] : undefined; + return reason && Cause.isFailReason(reason) + ? Effect.sync(() => { + expect(reason.error).toBe(failure); + }) + : Effect.die(new Error("Expected the original typed failure.")); + }), + ), + ).pipe(Layer.provide(tracingLayer)); + + return Layer.build(tracedApplication).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + expect(new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array)).toContain( + "mobile.test.failed-span", + ); + }), + ), + ); +}); diff --git a/apps/mobile/src/features/observability/mobileTracing.ts b/apps/mobile/src/features/observability/tracing.ts similarity index 64% rename from apps/mobile/src/features/observability/mobileTracing.ts rename to apps/mobile/src/features/observability/tracing.ts index dfc6f875c1b..eb73abba292 100644 --- a/apps/mobile/src/features/observability/mobileTracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -1,32 +1,29 @@ import Constants from "expo-constants"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; -import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; -export interface MobileTracingConfig { +export interface TracingConfig { readonly tracesUrl: string; readonly tracesDataset: string; readonly tracesToken: string; } -export interface MobileTracingResource { +export interface TracingResource { readonly serviceVersion?: string; readonly appVariant: string; } -export function resolveMobileTracingConfig(): MobileTracingConfig | null { +export function resolveTracingConfig(): TracingConfig | null { const config = resolveCloudPublicConfig(); - if (!hasMobileTracingPublicConfig(config)) { + if (!hasTracingPublicConfig(config)) { return null; } const { tracesUrl, tracesDataset, tracesToken } = config.observability; return { tracesUrl, tracesDataset, tracesToken }; } -export function makeMobileTracingLayer( - config: MobileTracingConfig | null, - resource: MobileTracingResource, -) { +export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { return makeRelayClientTracingLayer(config, { serviceName: "t3-mobile-relay-client", serviceVersion: resource.serviceVersion, @@ -35,7 +32,7 @@ export function makeMobileTracingLayer( }); } -export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), { +export const tracingLayer = makeTracingLayer(resolveTracingConfig(), { serviceVersion: Constants.expoConfig?.version, appVariant: typeof Constants.expoConfig?.extra?.appVariant === "string" diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a7423966f67..fa1f635de8d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -2,23 +2,25 @@ import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, - appendBrowsePathSegment, buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, - canNavigateUp, - ensureBrowseDirectoryPath, findExistingAddProject, getAddProjectInitialQuery, + resolveAddProjectPath, + sortAddProjectProviderSources, + type AddProjectRemoteSource, +} from "@t3tools/client-runtime/operations/projects"; +import { + appendBrowsePathSegment, + canNavigateUp, + ensureBrowseDirectoryPath, getBrowseDirectoryPath, getBrowseLeafPathSegment, getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, isFilesystemBrowseQuery, - resolveAddProjectPath, - sortAddProjectProviderSources, - type AddProjectRemoteSource, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; @@ -26,21 +28,23 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; +import * as Cause from "effect/Cause"; import * as Order from "effect/Order"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useProjects, useServerConfigs } from "../../state/entities"; +import { filesystemEnvironment } from "../../state/filesystem"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; import { useThemeColor } from "../../lib/useThemeColor"; import { uuidv4 } from "../../lib/uuid"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { useFilesystemBrowse } from "../../state/use-filesystem-browse"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; -import { - refreshSourceControlDiscoveryForEnvironment, - useSourceControlDiscovery, -} from "../../state/use-source-control-discovery"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -96,7 +100,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote function SectionTitle(props: { readonly children: string }) { return ( {props.children} @@ -164,9 +168,9 @@ function ListRow(props: { {props.icon} - {props.title} + {props.title} {props.subtitle ? ( - + {props.subtitle} ) : null} @@ -198,7 +202,7 @@ function PrimaryActionButton(props: { {props.loading ? ( ) : ( - {props.label} + {props.label} )} ); @@ -211,7 +215,7 @@ function ProjectPathInput(props: { }) { return ( { - const { serverConfigByEnvironmentId } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const serverConfigByEnvironmentId = useServerConfigs(); + const { savedConnectionsById } = useSavedRemoteConnections(); return useMemo>(() => { const options = Object.values(savedConnectionsById).map((connection) => { - const config = serverConfigByEnvironmentId[connection.environmentId]; + const config = serverConfigByEnvironmentId.get(connection.environmentId); return { environmentId: connection.environmentId, label: connection.environmentLabel, @@ -269,15 +273,15 @@ function EmptyEnvironmentState() { return ( - No environments connected - + No environments connected + Add an environment before adding a project. router.replace("/connections/new")} className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" > - Add environment + Add environment ); @@ -336,17 +340,19 @@ export function AddProjectSourceScreen() { const iconColor = useThemeColor("--color-icon"); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); - const discoveryState = useSourceControlDiscovery(selectedEnvironment?.environmentId ?? null); + const discoveryState = useEnvironmentQuery( + selectedEnvironment === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: selectedEnvironment.environmentId, + input: {}, + }), + ); const readiness = useMemo( () => buildAddProjectRemoteSourceReadiness(discoveryState.data), [discoveryState.data], ); - useEffect(() => { - if (!selectedEnvironment) return; - void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId); - }, [selectedEnvironment]); - return ( {environmentOptions.length === 0 ? : null} @@ -435,13 +441,12 @@ export function AddProjectSourceScreen() { function useCreateProject(environment: EnvironmentOption | null) { const router = useRouter(); - const { projects } = useRemoteCatalog(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const projects = useProjects(); return useCallback( async (workspaceRoot: string) => { if (!environment) return; - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); const existing = findExistingAddProject({ projects, @@ -462,14 +467,19 @@ function useCreateProject(environment: EnvironmentOption | null) { } const projectId = ProjectId.make(uuidv4()); - await client.orchestration.dispatchCommand( - buildProjectCreateCommand({ - commandId: CommandId.make(uuidv4()), - projectId, - workspaceRoot, - createdAt: new Date().toISOString(), - }), - ); + const command = buildProjectCreateCommand({ + commandId: CommandId.make(uuidv4()), + projectId, + workspaceRoot, + createdAt: new Date().toISOString(), + }); + const result = await createProject({ + environmentId: environment.environmentId, + input: command, + }); + if (AsyncResult.isFailure(result)) { + return result; + } router.replace({ pathname: "/new/draft", params: { @@ -478,8 +488,9 @@ function useCreateProject(environment: EnvironmentOption | null) { title: inferProjectTitleFromPath(workspaceRoot), }, }); + return result; }, - [environment, projects, router], + [createProject, environment, projects, router], ); } @@ -495,6 +506,9 @@ function useEnvironmentFromParam(): EnvironmentOption | null { } export function AddProjectRepositoryScreen() { + const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { + reportFailure: false, + }); const router = useRouter(); const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); const environment = useEnvironmentFromParam(); @@ -507,28 +521,33 @@ export function AddProjectRepositoryScreen() { if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; setError(null); setIsSubmitting(true); - try { - const provider = addProjectRemoteSourceProvider(source); - if (!provider) { - const remoteUrl = repositoryInput.trim(); - router.push({ - pathname: "/new/add-project/destination", - params: { - environmentId: environment.environmentId, - source, - remoteUrl, - repositoryTitle: remoteUrl, - }, - }); - return; - } + const provider = addProjectRemoteSourceProvider(source); + if (!provider) { + const remoteUrl = repositoryInput.trim(); + router.push({ + pathname: "/new/add-project/destination", + params: { + environmentId: environment.environmentId, + source, + remoteUrl, + repositoryTitle: remoteUrl, + }, + }); + setIsSubmitting(false); + return; + } - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); - const repository = await client.sourceControl.lookupRepository({ + const result = await lookupRepositoryQuery({ + environmentId: environment.environmentId, + input: { provider, repository: repositoryInput.trim(), - }); + }, + }); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + } else { + const repository = result.value; router.push({ pathname: "/new/add-project/destination", params: { @@ -538,18 +557,15 @@ export function AddProjectRepositoryScreen() { repositoryTitle: repository.nameWithOwner, }, }); - } catch (nextError) { - setError(errorMessage(nextError)); - } finally { - setIsSubmitting(false); } - }, [environment, isSubmitting, repositoryInput, router, source]); + setIsSubmitting(false); + }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]); return ( {error ? : null} (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), [browseDirectoryPath], ); - const browseState = useFilesystemBrowse(props.environment.environmentId, browseInput); + const browseState = useEnvironmentQuery( + browseInput === null + ? null + : filesystemEnvironment.browse({ + environmentId: props.environment.environmentId, + input: browseInput, + }), + ); const visibleBrowseEntries = useMemo( () => Arr.sort( @@ -686,13 +709,11 @@ export function AddProjectLocalFolderScreen() { } setIsSubmitting(true); - try { - await createProject(resolved.path); - } catch (nextError) { - setError(errorMessage(nextError)); - } finally { - setIsSubmitting(false); + const result = await createProject(resolved.path); + if (result && AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); } + setIsSubmitting(false); }, [createProject, environment, isSubmitting, pathInput]); return ( @@ -725,6 +746,9 @@ export function AddProjectLocalFolderScreen() { } export function AddProjectDestinationScreen() { + const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { + reportFailure: false, + }); const params = useLocalSearchParams<{ environmentId?: string; remoteUrl?: string; @@ -759,28 +783,31 @@ export function AddProjectDestinationScreen() { } setIsSubmitting(true); - try { - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); - const result = await client.sourceControl.cloneRepository({ + const cloneResult = await cloneRepository({ + environmentId: environment.environmentId, + input: { remoteUrl, destinationPath: resolved.path, - }); - await createProject(result.cwd); - } catch (nextError) { - setError(errorMessage(nextError)); - } finally { - setIsSubmitting(false); + }, + }); + if (AsyncResult.isFailure(cloneResult)) { + setError(errorMessage(Cause.squash(cloneResult.cause))); + } else { + const createResult = await createProject(cloneResult.value.cwd); + if (createResult && AsyncResult.isFailure(createResult)) { + setError(errorMessage(Cause.squash(createResult.cause))); + } } - }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); + setIsSubmitting(false); + }, [cloneRepository, createProject, environment, isSubmitting, pathInput, remoteUrl]); return ( {error ? : null} {repositoryTitle ? ( - {repositoryTitle} - + {repositoryTitle} + {remoteUrl} diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index 65255c14ff3..d35c48e8a9b 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -159,26 +159,26 @@ export function ReviewCommentComposerSheet() { - Add Comment + Add Comment {!target ? ( - No selection - + No selection + Select a diff line or range first. ) : ( - + {selectionLabel} @@ -215,7 +215,7 @@ export function ReviewCommentComposerSheet() { > {lineNumber ?? ""} @@ -236,7 +236,7 @@ export function ReviewCommentComposerSheet() { - Comment + Comment @@ -248,7 +248,7 @@ export function ReviewCommentComposerSheet() { textAlignVertical="top" value={commentText} onChangeText={setCommentText} - className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-[15px]" + className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base" style={{ flex: 1, minHeight: 0 }} /> diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index c82ca71596a..92203c0ed4e 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -16,8 +16,13 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { environmentCatalog } from "../../connection/catalog"; +import { useEnvironmentPresentation } from "../../state/presentation"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useThemeColor } from "../../lib/useThemeColor"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; +import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useReviewCacheForThread } from "./reviewState"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { @@ -29,6 +34,7 @@ import { useReviewFileVisibility } from "./reviewFileVisibility"; import { useReviewSections } from "./useReviewSections"; import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; +import { resolveReviewAvailability } from "./reviewAvailability"; const IOS_NAV_BAR_HEIGHT = 44; const REVIEW_HEADER_SPACING = 0; @@ -36,10 +42,10 @@ const REVIEW_HEADER_SPACING = 0; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - + Partial diff - + {props.notice} @@ -64,7 +70,7 @@ function ReviewSelectionActionBar(props: { tintColor="#ffffff" type="monochrome" /> - {props.title} + {props.title} ); @@ -114,6 +120,9 @@ export function ReviewSheet() { environmentId: EnvironmentId; threadId: ThreadId; }>(); + const environment = useEnvironmentPresentation(environmentId); + const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const selectedTheme = colorScheme === "dark" ? "dark" : "light"; @@ -126,7 +135,12 @@ export function ReviewSheet() { selectedSection, refreshSelectedSection, selectSection, - } = useReviewSections({ environmentId, threadId, reviewCache }); + } = useReviewSections({ + enabled: isEnvironmentReady, + environmentId, + threadId, + reviewCache, + }); const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = useReviewDiffData({ threadKey: reviewCache.threadKey, @@ -187,6 +201,17 @@ export function ReviewSheet() { const parsedDiffNotice = parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; + const hasCachedSelectedDiff = selectedSection?.diff != null; + const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); + const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ + hasEnvironmentPresentation: environment.isReady, + isEnvironmentConnected: isEnvironmentReady, + hasCachedSelectedDiff, + hasAnyCachedDiff, + }); + const handleRetryEnvironment = useCallback(() => { + void retryEnvironment(environmentId); + }, [environmentId, retryEnvironment]); const listHeader = useMemo(() => { const children: ReactElement[] = []; @@ -194,8 +219,8 @@ export function ReviewSheet() { if (error) { children.push( - Review unavailable - {error} + Review unavailable + {error} , ); } @@ -227,7 +252,7 @@ export function ReviewSheet() { numberOfLines={1} style={{ fontFamily: "DMSans_700Bold", - fontSize: 18, + fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, fontWeight: "900", color: headerForeground, letterSpacing: -0.4, @@ -249,7 +274,7 @@ export function ReviewSheet() { - - - {reviewSections.map((section) => ( + {showSectionToolbar ? ( + + + {reviewSections.map((section) => ( + selectSection(section.id)} + subtitle={section.subtitle ?? undefined} + > + {section.title} + + ))} selectSection(section.id)} - subtitle={section.subtitle ?? undefined} + icon="arrow.clockwise" + disabled={ + loadingGitDiffs || + (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) + } + onPress={() => void refreshSelectedSection()} + subtitle="Reload current diff" > - {section.title} + Refresh - ))} - void refreshSelectedSection()} - subtitle="Reload current diff" - > - Refresh - - - + + + ) : null} - {selectedSection && parsedDiff.kind === "files" ? ( + {showConnectionNotice ? ( + + + + ) : selectedSection && parsedDiff.kind === "files" ? ( - No review diffs - + No review diffs + This thread has no ready turn diffs and the worktree diff is empty. ) : selectedSection.isLoading && selectedSection.diff === null ? ( - Loading diff… + Loading diff… ) : parsedDiff.kind === "empty" ? ( - No changes - + No changes + {selectedSection.subtitle ?? "This diff is empty."} ) : parsedDiff.kind === "raw" ? ( - + {parsedDiff.reason} - + {parsedDiff.text} diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index d747dfc531b..f60fdfe70e0 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -5,6 +5,7 @@ import type { } from "../diffs/nativeReviewDiffTypes"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; import { @@ -18,16 +19,16 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; -export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = 20; +export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; export const NATIVE_REVIEW_DIFF_STYLE = { rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT, contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, changeBarWidth: 4, - gutterWidth: 46, - codePadding: 7, - textVerticalInset: 2, + gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, + codePadding: MOBILE_CODE_SURFACE.codePadding, + textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, fileHeaderHeight: 56, fileHeaderHorizontalMargin: 8, fileHeaderVerticalMargin: 6, @@ -36,9 +37,9 @@ export const NATIVE_REVIEW_DIFF_STYLE = { fileHeaderPathRightPadding: 118, fileHeaderCountColumnWidth: 38, fileHeaderCountGap: 5, - codeFontSize: 11, + codeFontSize: MOBILE_CODE_SURFACE.fontSize, codeFontWeight: "regular", - lineNumberFontSize: 10, + lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, lineNumberFontWeight: "regular", hunkFontSize: 11, hunkFontWeight: "medium", diff --git a/apps/mobile/src/features/review/reviewAvailability.test.ts b/apps/mobile/src/features/review/reviewAvailability.test.ts new file mode 100644 index 00000000000..bd25d47a7af --- /dev/null +++ b/apps/mobile/src/features/review/reviewAvailability.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveReviewAvailability } from "./reviewAvailability"; + +describe("resolveReviewAvailability", () => { + it("keeps section navigation available when another section is cached offline", () => { + expect( + resolveReviewAvailability({ + hasEnvironmentPresentation: true, + isEnvironmentConnected: false, + hasCachedSelectedDiff: false, + hasAnyCachedDiff: true, + }), + ).toEqual({ + showConnectionNotice: true, + showSectionToolbar: true, + }); + }); + + it("hides section navigation when no review section is available offline", () => { + expect( + resolveReviewAvailability({ + hasEnvironmentPresentation: true, + isEnvironmentConnected: false, + hasCachedSelectedDiff: false, + hasAnyCachedDiff: false, + }), + ).toEqual({ + showConnectionNotice: true, + showSectionToolbar: false, + }); + }); + + it("shows cached selected content and navigation while offline", () => { + expect( + resolveReviewAvailability({ + hasEnvironmentPresentation: true, + isEnvironmentConnected: false, + hasCachedSelectedDiff: true, + hasAnyCachedDiff: true, + }), + ).toEqual({ + showConnectionNotice: false, + showSectionToolbar: true, + }); + }); +}); diff --git a/apps/mobile/src/features/review/reviewAvailability.ts b/apps/mobile/src/features/review/reviewAvailability.ts new file mode 100644 index 00000000000..5e6b1da9bb7 --- /dev/null +++ b/apps/mobile/src/features/review/reviewAvailability.ts @@ -0,0 +1,19 @@ +export function resolveReviewAvailability(input: { + readonly hasEnvironmentPresentation: boolean; + readonly isEnvironmentConnected: boolean; + readonly hasCachedSelectedDiff: boolean; + readonly hasAnyCachedDiff: boolean; +}): { + readonly showConnectionNotice: boolean; + readonly showSectionToolbar: boolean; +} { + const showConnectionNotice = + input.hasEnvironmentPresentation && + !input.isEnvironmentConnected && + !input.hasCachedSelectedDiff; + + return { + showConnectionNotice, + showSectionToolbar: !showConnectionNotice || input.hasAnyCachedDiff, + }; +} diff --git a/apps/mobile/src/features/review/reviewDiffPreviewState.ts b/apps/mobile/src/features/review/reviewDiffPreviewState.ts deleted file mode 100644 index d0f85cd6d89..00000000000 --- a/apps/mobile/src/features/review/reviewDiffPreviewState.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; - -import { appAtomRegistry } from "../../state/atom-registry"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; - -const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000; -const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000; -const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f"; - -export interface ReviewDiffPreviewState { - readonly data: ReviewDiffPreviewResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -function makeReviewDiffPreviewKey(input: { - readonly environmentId: EnvironmentId; - readonly cwd: string; -}): string { - return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`; -} - -function parseReviewDiffPreviewKey(key: string): { - readonly environmentId: EnvironmentId; - readonly cwd: string; -} { - const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR); - return { - environmentId: environmentId as EnvironmentId, - cwd, - }; -} - -const reviewDiffPreviewAtom = Atom.family((key: string) => - Atom.make( - Effect.promise(async (): Promise => { - const target = parseReviewDiffPreviewKey(key); - const client = getEnvironmentClient(target.environmentId); - if (!client) { - throw new Error("Remote connection is not ready."); - } - return client.review.getDiffPreview({ cwd: target.cwd }); - }), - ).pipe( - Atom.swr({ - staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS), - Atom.withLabel(`mobile:review:diff-preview:${key}`), - ), -); - -const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make( - AsyncResult.initial(false), -).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null")); - -function readReviewDiffPreviewError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const error = Cause.squash(result.cause); - return error instanceof Error ? error.message : "Failed to load review diffs."; -} - -export function useReviewDiffPreview(input: { - readonly environmentId?: EnvironmentId; - readonly cwd: string | null; -}): ReviewDiffPreviewState { - const key = useMemo(() => { - if (!input.environmentId || !input.cwd) { - return null; - } - return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd }); - }, [input.cwd, input.environmentId]); - - const atom = key ? reviewDiffPreviewAtom(key) : null; - const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM); - const refresh = useCallback(() => { - if (atom) { - appAtomRegistry.refresh(atom); - } - }, [atom]); - - if (!atom) { - return { - data: null, - error: null, - isPending: false, - refresh, - }; - } - - return { - data: Option.getOrNull(AsyncResult.value(result)), - error: readReviewDiffPreviewError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index 3f2ae01609e..14ff0276657 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -1,6 +1,7 @@ import { Platform, Text as NativeText, View } from "react-native"; import { cn } from "../../lib/cn"; +import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import type { ReviewRenderableLineRow } from "./reviewModel"; import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; @@ -11,7 +12,7 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ default: "monospace", }); -export const REVIEW_DIFF_LINE_HEIGHT = 26; +export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2; export function renderVisibleWhitespace(value: string): string { @@ -71,8 +72,12 @@ export function DiffTokenText(props: { {renderVisibleWhitespace(props.fallback || " ")} @@ -83,8 +88,12 @@ export function DiffTokenText(props: { {(() => { let offset = 0; diff --git a/apps/mobile/src/features/review/reviewHighlighterState.test.ts b/apps/mobile/src/features/review/reviewHighlighterState.test.ts index 43ec2e04182..9cc43d07f2a 100644 --- a/apps/mobile/src/features/review/reviewHighlighterState.test.ts +++ b/apps/mobile/src/features/review/reviewHighlighterState.test.ts @@ -53,11 +53,12 @@ it("initializes review highlighter state once", async () => { }); it("stores initialization failures in atom state", async () => { + const cause = new Error("load failed"); const manager = createReviewHighlighterManager({ getRegistry: () => registry, loader: { prepare: async () => { - throw new Error("load failed"); + throw cause; }, prepareLanguages: async () => undefined, getEngine: async () => "javascript", @@ -67,9 +68,23 @@ it("stores initialization failures in atom state", async () => { void manager.initialize(); await flushAsyncWork(); - assert.deepStrictEqual(manager.getSnapshot(), { - engine: null, - error: "load failed", - status: "error", - }); + const snapshot = manager.getSnapshot(); + assert.strictEqual(snapshot.engine, null); + assert.strictEqual(snapshot.status, "error"); + assert.strictEqual(snapshot.error?._tag, "ReviewHighlighterManagerError"); + assert.strictEqual(snapshot.error?.operation, "prepare"); + assert.deepStrictEqual(snapshot.error?.languages, [ + "typescript", + "tsx", + "javascript", + "jsx", + "json", + "yaml", + "bash", + ]); + assert.strictEqual(snapshot.error?.cause, cause); + assert.strictEqual( + snapshot.error?.message, + "Review highlighter operation prepare failed for languages typescript, tsx, javascript, jsx, json, yaml, bash.", + ); }); diff --git a/apps/mobile/src/features/review/reviewHighlighterState.ts b/apps/mobile/src/features/review/reviewHighlighterState.ts index 2622ecbe050..51b20bb07ff 100644 --- a/apps/mobile/src/features/review/reviewHighlighterState.ts +++ b/apps/mobile/src/features/review/reviewHighlighterState.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; import { useEffect } from "react"; @@ -12,9 +13,22 @@ import { export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error"; +export class ReviewHighlighterManagerError extends Schema.TaggedErrorClass()( + "ReviewHighlighterManagerError", + { + operation: Schema.Literals(["prepare", "prepare-languages", "resolve-engine"]), + languages: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Review highlighter operation ${this.operation} failed for languages ${this.languages.join(", ")}.`; + } +} + export interface ReviewHighlighterState { readonly engine: ReviewHighlighterEngine | null; - readonly error: string | null; + readonly error: ReviewHighlighterManagerError | null; readonly status: ReviewHighlighterStatus; } @@ -101,24 +115,35 @@ export function createReviewHighlighterManager(config: { inFlight = (async () => { const startedAt = performance.now(); + const languages = config.languages ?? REVIEW_INITIAL_LANGUAGES; + let operation: ReviewHighlighterManagerError["operation"] = "prepare"; + let engine: ReviewHighlighterEngine; try { await config.loader.prepare(); - await config.loader.prepareLanguages(config.languages ?? REVIEW_INITIAL_LANGUAGES); - const engine = await config.loader.getEngine(); - const durationMs = Math.round(performance.now() - startedAt); - logReviewHighlighterProviderDiagnostic("initialized", { - durationMs, - engine, + operation = "prepare-languages"; + await config.loader.prepareLanguages(languages); + operation = "resolve-engine"; + engine = await config.loader.getEngine(); + } catch (cause) { + const error = new ReviewHighlighterManagerError({ + operation, + languages, + cause, }); - setState({ engine, error: null, status: "ready" }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logReviewHighlighterProviderDiagnostic("initialization failed", { error: message }); - setState({ engine: null, error: message, status: "error" }); - } finally { - inFlight = null; + logReviewHighlighterProviderDiagnostic("initialization failed", { error }); + setState({ engine: null, error, status: "error" }); + return; } - })(); + + const durationMs = Math.round(performance.now() - startedAt); + logReviewHighlighterProviderDiagnostic("initialized", { + durationMs, + engine, + }); + setState({ engine, error: null, status: "ready" }); + })().finally(() => { + inFlight = null; + }); return inFlight; } diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index d6d09221dac..008a0761949 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -10,6 +10,7 @@ import yamlLanguage from "@shikijs/langs/yaml"; import githubDarkDefault from "@shikijs/themes/github-dark-default"; import githubLightDefault from "@shikijs/themes/github-light-default"; import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; +import * as Schema from "effect/Schema"; import { resolveReviewHighlighterEngine, @@ -22,6 +23,19 @@ import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordD export type ReviewDiffTheme = "light" | "dark"; export type { ReviewHighlighterEngine }; +export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErrorClass()( + "ReviewHighlighterEngineInitializationError", + { + preferredEngine: Schema.Literals(["native", "javascript"]), + attemptedEngine: Schema.Literals(["native", "javascript"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the ${this.attemptedEngine} review highlighter with ${this.preferredEngine} preferred.`; + } +} + export interface ReviewHighlightedToken { content: string; readonly color: string | null; @@ -227,16 +241,6 @@ function logReviewHighlighterDiagnosticError(message: string, error: unknown): v if (!isReviewHighlighterDebugLoggingEnabled()) { return; } - - if (error instanceof Error) { - console.error(`[review-highlighter] ${message}`, { - name: error.name, - message: error.message, - stack: error.stack, - }); - return; - } - console.error(`[review-highlighter] ${message}`, error); } @@ -258,6 +262,7 @@ async function getHighlighter(): Promise { if (!highlighterPromise) { const configuredHighlighterPromise = (async () => { let nativeEngineAvailable = false; + let nativeInitializationError: ReviewHighlighterEngineInitializationError | undefined; logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, @@ -289,9 +294,14 @@ async function getHighlighter(): Promise { }; } } catch (error) { + nativeInitializationError = new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "native", + cause: error, + }); logReviewHighlighterDiagnosticError( "native engine initialization failed; falling back to javascript", - error, + nativeInitializationError, ); nativeEngineAvailable = false; } @@ -305,11 +315,30 @@ async function getHighlighter(): Promise { REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, nativeEngineAvailable, ); - const highlighter = await createHighlighterCore({ - themes, - langs: REVIEW_INITIAL_LANGUAGE_MODULES, - engine: createJavaScriptRegexEngine(), - }); + let highlighter: HighlighterCore; + try { + highlighter = await createHighlighterCore({ + themes, + langs: REVIEW_INITIAL_LANGUAGE_MODULES, + engine: createJavaScriptRegexEngine(), + }); + } catch (cause) { + const javascriptError = new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "javascript", + cause, + }); + if (!nativeInitializationError) throw javascriptError; + throw new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "javascript", + cause: new AggregateError( + [nativeInitializationError, javascriptError], + "Native and JavaScript review highlighter initialization failed.", + { cause: nativeInitializationError }, + ), + }); + } logReviewHighlighterDiagnostic("using javascript engine", { resolvedEngine: engine, }); @@ -695,6 +724,15 @@ export async function highlightCodeSnippet(input: { return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); } +export async function highlightSourceFile(input: { + readonly path: string; + readonly contents: string; + readonly theme: ReviewDiffTheme; +}): Promise>> { + const language = await resolveLanguageFromPath(input.path); + return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); +} + async function highlightPatchLinesInChunks(input: { readonly lines: ReadonlyArray; readonly language: string; diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 98df26641a9..35f06c26366 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -108,7 +108,11 @@ export function useNativeReviewDiffHighlighting(input: { } catch (error) { if (!abortController.signal.aborted) { logReviewDiffDiagnostic("native visible highlight failed", { - error: error instanceof Error ? error.message : String(error), + error, + resetKey, + scheme, + firstRowIndex: requestRange.firstRowIndex, + lastRowIndex: requestRange.lastRowIndex, }); } } diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 4c5a1abffb1..87325490990 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff"; -import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useCheckpointDiff } from "../../state/queries"; +import { useEnvironmentQuery } from "../../state/query"; +import { reviewEnvironment } from "../../state/review"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; -import { useReviewDiffPreview } from "./reviewDiffPreviewState"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { buildReviewSectionItems, getDefaultReviewSectionId, @@ -17,29 +17,30 @@ import { setReviewAsyncError, setReviewGitSections, setReviewSelectedSectionId, - setReviewTurnDiffLoading, setReviewTurnDiff, + setReviewTurnDiffLoading, type ReviewCacheForThread, } from "./reviewState"; export function useReviewSections(input: { + readonly enabled?: boolean; readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; readonly reviewCache: ReviewCacheForThread; }) { const { environmentId, reviewCache, threadId } = input; + const enabled = input.enabled ?? true; const selectedThread = useSelectedThreadDetail(); const { selectedThreadCwd } = useSelectedThreadWorktree(); - const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd }); - const refreshDiffPreview = diffPreview.refresh; + const diffPreview = useEnvironmentQuery( + enabled && environmentId !== undefined && selectedThreadCwd !== null + ? reviewEnvironment.diffPreview({ + environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const { loadingTurnIds } = reviewCache.asyncState; - const error = diffPreview.error ?? reviewCache.asyncState.error; - const loadingGitDiffs = diffPreview.isPending; - const turnDiffByIdRef = useRef(reviewCache.turnDiffById); - - useEffect(() => { - turnDiffByIdRef.current = reviewCache.turnDiffById; - }, [reviewCache.turnDiffById]); useEffect(() => { if (reviewCache.threadKey && diffPreview.data) { @@ -51,14 +52,16 @@ export function useReviewSections(input: { () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []), [selectedThread?.checkpoints], ); - const checkpointBySectionId = useMemo(() => { - return Object.fromEntries( - readyCheckpoints.map((checkpoint) => [ - getReviewSectionIdForCheckpoint(checkpoint), - checkpoint, - ]), - ) as Record; - }, [readyCheckpoints]); + const checkpointBySectionId = useMemo( + () => + Object.fromEntries( + readyCheckpoints.map((checkpoint) => [ + getReviewSectionIdForCheckpoint(checkpoint), + checkpoint, + ]), + ) as Record, + [readyCheckpoints], + ); const reviewSections = useMemo( () => buildReviewSectionItems({ @@ -87,7 +90,6 @@ export function useReviewSections(input: { () => getDefaultReviewSectionId(reviewSections), [reviewSections], ); - const hasReviewSections = reviewSections.length > 0; const selectedSectionIdExists = useMemo( () => reviewCache.selectedSectionId @@ -96,140 +98,69 @@ export function useReviewSections(input: { [reviewCache.selectedSectionId, reviewSections], ); - const loadTurnDiff = useCallback( - async (checkpoint: OrchestrationCheckpointSummary, force = false) => { - if (!environmentId || !threadId) { - return; - } - - const sectionId = getReviewSectionIdForCheckpoint(checkpoint); - if (reviewCache.threadKey) { - setReviewSelectedSectionId(reviewCache.threadKey, sectionId); - } - - if (!force && turnDiffByIdRef.current[sectionId] !== undefined) { - return; - } - - const target = { - environmentId, - threadId, - fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), - toTurnCount: checkpoint.checkpointTurnCount, - ignoreWhitespace: false, - cacheScope: sectionId, - }; - const cached = checkpointDiffManager.getSnapshot(target).data; - if (!force && cached) { - if (reviewCache.threadKey) { - setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff); - } - return; - } - - if (!getEnvironmentClient(environmentId)) { - if (reviewCache.threadKey) { - setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready."); - } - return; - } - - if (reviewCache.threadKey) { - setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true); - setReviewAsyncError(reviewCache.threadKey, null); - } - try { - const result = await loadCheckpointDiff(target, { force }); - if (reviewCache.threadKey) { - if (result) { - setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff); - } - } - } catch (cause) { - if (reviewCache.threadKey) { - setReviewAsyncError( - reviewCache.threadKey, - cause instanceof Error ? cause.message : "Failed to load turn diff.", - ); - } - } finally { - if (reviewCache.threadKey) { - setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false); - } - } - }, - [environmentId, reviewCache.threadKey, threadId], - ); - useEffect(() => { - if (!hasReviewSections) { - return; - } - - if (reviewCache.threadKey && (!reviewCache.selectedSectionId || !selectedSectionIdExists)) { + if ( + reviewSections.length > 0 && + reviewCache.threadKey && + (!reviewCache.selectedSectionId || !selectedSectionIdExists) + ) { setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); } }, [ fallbackSectionId, - hasReviewSections, reviewCache.selectedSectionId, reviewCache.threadKey, + reviewSections.length, selectedSectionIdExists, ]); - const latestCheckpoint = readyCheckpoints[0] ?? null; - const latestSectionId = latestCheckpoint - ? getReviewSectionIdForCheckpoint(latestCheckpoint) + let activeCheckpoint = readyCheckpoints[0] ?? null; + if (selectedSection?.kind === "turn") { + activeCheckpoint = checkpointBySectionId[selectedSection.id] ?? activeCheckpoint; + } + const activeSectionId = activeCheckpoint + ? getReviewSectionIdForCheckpoint(activeCheckpoint) : null; - const latestTurnDiffLoaded = latestSectionId - ? reviewCache.turnDiffById[latestSectionId] !== undefined - : true; - const latestTurnDiffLoading = latestSectionId ? loadingTurnIds[latestSectionId] === true : false; + const activeTurnDiff = useCheckpointDiff({ + environmentId: enabled ? (environmentId ?? null) : null, + threadId: enabled ? (threadId ?? null) : null, + fromTurnCount: + enabled && activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null, + toTurnCount: enabled ? (activeCheckpoint?.checkpointTurnCount ?? null) : null, + ignoreWhitespace: false, + }); useEffect(() => { - if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) { + if (!reviewCache.threadKey || !activeSectionId) { return; } - - void loadTurnDiff(latestCheckpoint); - }, [ - latestCheckpoint, - latestSectionId, - latestTurnDiffLoaded, - latestTurnDiffLoading, - loadTurnDiff, - ]); - - const selectedTurnCheckpoint = - selectedSection?.kind === "turn" ? (checkpointBySectionId[selectedSection.id] ?? null) : null; - const selectedTurnDiffMissing = - selectedSection?.kind === "turn" && selectedSection.diff === null && selectedTurnCheckpoint; - const selectedTurnDiffLoading = - selectedSection?.kind === "turn" ? loadingTurnIds[selectedSection.id] === true : false; + setReviewTurnDiffLoading(reviewCache.threadKey, activeSectionId, activeTurnDiff.isPending); + }, [activeSectionId, activeTurnDiff.isPending, reviewCache.threadKey]); useEffect(() => { - if (!selectedTurnDiffMissing || selectedTurnDiffLoading) { + if (!reviewCache.threadKey || !activeSectionId || !activeTurnDiff.data) { return; } + setReviewTurnDiff(reviewCache.threadKey, activeSectionId, activeTurnDiff.data.diff); + setReviewAsyncError(reviewCache.threadKey, null); + }, [activeSectionId, activeTurnDiff.data, reviewCache.threadKey]); - void loadTurnDiff(selectedTurnDiffMissing); - }, [loadTurnDiff, selectedTurnDiffLoading, selectedTurnDiffMissing]); + useEffect(() => { + if (reviewCache.threadKey && activeTurnDiff.error) { + setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error); + } + }, [activeTurnDiff.error, reviewCache.threadKey]); const refreshSelectedSection = useCallback(async () => { - if (!selectedSection) { + if (!enabled) { return; } - - if (selectedSection.kind === "turn") { - const checkpoint = checkpointBySectionId[selectedSection.id]; - if (checkpoint) { - await loadTurnDiff(checkpoint, true); - } + if (selectedSection?.kind === "turn") { + activeTurnDiff.refresh(); return; } - - refreshDiffPreview(); - }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]); + diffPreview.refresh(); + }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]); const selectSection = useCallback( (sectionId: string) => { @@ -241,8 +172,8 @@ export function useReviewSections(input: { ); return { - error, - loadingGitDiffs, + error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, + loadingGitDiffs: diffPreview.isPending, loadingTurnIds, reviewSections, selectedSection, diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 9d846a5fff2..ad693dcb445 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -11,6 +11,7 @@ import { } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule"; import { buildGhosttyThemeConfig, @@ -53,7 +54,7 @@ function estimateGridSize(input: { } const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { - const fontSize = props.fontSize ?? 12; + const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const inputRef = useRef(null); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); @@ -93,7 +94,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter @@ -140,7 +141,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter color: theme.foreground, flex: 1, fontFamily: "Menlo", - fontSize: 13, + fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, padding: 0, }} onSubmitEditing={(event) => { @@ -165,7 +166,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter style={{ color: theme.foreground, fontFamily: "DMSans_700Bold", - fontSize: 11, + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, }} > Ctrl-C @@ -177,7 +178,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter }); export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { - const fontSize = props.fontSize ?? 12; + const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const keyboardInputRef = useRef(null); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index 71643336d54..5d0b0547e6e 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -1,18 +1,19 @@ import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; import { SymbolView } from "expo-symbols"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { - attachTerminalSession, - useTerminalSession, - useTerminalSessionTarget, -} from "../../state/use-terminal-session"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAttachedTerminalSession } from "../../state/use-terminal-session"; import { TerminalSurface } from "./NativeTerminalSurface"; import { hasNativeTerminalSurface } from "./nativeTerminalModule"; -import { terminalDebugLog } from "./terminalDebugLog"; +import { + buildThreadTerminalAttachInput, + type TerminalGridSize, + type ThreadTerminalSubscriptionIdentity, +} from "./threadTerminalPanelModel"; interface ThreadTerminalPanelProps { readonly environmentId: EnvironmentId; @@ -29,108 +30,93 @@ const DEFAULT_TERMINAL_ROWS = 24; export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( props: ThreadTerminalPanelProps, ) { + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); const nativeTerminalAvailable = hasNativeTerminalSurface(); const terminalId = DEFAULT_TERMINAL_ID; - const target = useTerminalSessionTarget({ - environmentId: props.environmentId, - threadId: props.threadId, - terminalId, - }); - const terminal = useTerminalSession(target); - const [lastGridSize, setLastGridSize] = useState({ + const lastGridSizeRef = useRef({ cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, }); - const lastGridSizeRef = useRef(lastGridSize); - lastGridSizeRef.current = lastGridSize; + const subscriptionIdentity = useMemo( + () => ({ + environmentId: props.environmentId, + threadId: props.threadId, + terminalId, + cwd: props.cwd, + worktreePath: props.worktreePath, + }), + [props.cwd, props.environmentId, props.threadId, props.worktreePath, terminalId], + ); + const attachInput = useMemo( + () => + props.visible + ? buildThreadTerminalAttachInput(subscriptionIdentity, lastGridSizeRef.current) + : null, + [props.visible, subscriptionIdentity], + ); + const terminal = useAttachedTerminalSession({ + environmentId: props.environmentId, + terminal: attachInput, + }); const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; const isRunning = terminal.status === "running" || terminal.status === "starting"; - useEffect(() => { - if (!props.visible) { - return; - } - - const client = getEnvironmentClient(props.environmentId); - if (!client) { - terminalDebugLog("panel:attach-skip", { - reason: "no-environment-client", + const sendResize = useCallback( + (size: TerminalGridSize) => { + void resizeTerminal({ environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + cols: size.cols, + rows: size.rows, + }, }); - return; - } - - terminalDebugLog("panel:attach", { - environmentId: props.environmentId, - threadId: props.threadId, - terminalId, - }); + }, + [props.environmentId, props.threadId, resizeTerminal, terminalId], + ); - return attachTerminalSession({ - environmentId: props.environmentId, - client, - terminal: { - threadId: props.threadId, - terminalId, - cwd: props.cwd, - worktreePath: props.worktreePath, - cols: lastGridSizeRef.current.cols, - rows: lastGridSizeRef.current.rows, - }, - }); - }, [ - props.cwd, - props.environmentId, - props.threadId, - props.worktreePath, - props.visible, - terminalId, - ]); + useEffect(() => { + if (isRunning) { + sendResize(lastGridSizeRef.current); + } + }, [isRunning, sendResize]); const handleInput = useCallback( (data: string) => { - const client = getEnvironmentClient(props.environmentId); - if (!client || !isRunning) { + if (!isRunning) { return; } - void client.terminal.write({ - threadId: props.threadId, - terminalId, - data, + void writeTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + data, + }, }); }, - [isRunning, props.environmentId, props.threadId, terminalId], + [isRunning, props.environmentId, props.threadId, terminalId, writeTerminal], ); const handleResize = useCallback( - (size: { readonly cols: number; readonly rows: number }) => { - if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { + (size: TerminalGridSize) => { + const previousSize = lastGridSizeRef.current; + if (size.cols === previousSize.cols && size.rows === previousSize.rows) { return; } - setLastGridSize(size); - const client = getEnvironmentClient(props.environmentId); - if (!client || !isRunning) { + lastGridSizeRef.current = size; + if (!isRunning) { return; } - void client.terminal.resize({ - threadId: props.threadId, - terminalId, - cols: size.cols, - rows: size.rows, - }); + sendResize(size); }, - [ - isRunning, - lastGridSize.cols, - lastGridSize.rows, - props.environmentId, - props.threadId, - terminalId, - ], + [isRunning, sendResize], ); if (!props.visible) { @@ -141,16 +127,16 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( - + Terminal - + {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} {terminal.error ? ( - + {terminal.error} ) : null} diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index e4ac3cc5c8b..8e9a47a58b5 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,10 +1,5 @@ -import { - DEFAULT_TERMINAL_ID, - EnvironmentId, - type TerminalAttachStreamEvent, - ThreadId, -} from "@t3tools/contracts"; -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "expo-symbols"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,17 +19,20 @@ import { import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; +import { environmentCatalog } from "../../connection/catalog"; +import { useEnvironmentPresentation } from "../../state/presentation"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useWorkspaceState } from "../../state/workspace"; import { buildThreadTerminalNavigation } from "../../lib/routes"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { - attachTerminalSession, + useAttachedTerminalSession, useKnownTerminalSessions, - useTerminalSession, - useTerminalSessionTarget, } from "../../state/use-terminal-session"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { TerminalSurface } from "./NativeTerminalSurface"; import { getPierreTerminalTheme } from "./terminalTheme"; import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; @@ -44,11 +42,10 @@ import { getTerminalSurfaceReplayBuffer, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, } from "./terminalBufferReplay"; -import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; import { resolveTerminalOpenLocation, - stagePendingTerminalLaunch, takePendingTerminalLaunch, + type PendingTerminalLaunch, } from "./terminalLaunchContext"; import { basename, @@ -158,8 +155,12 @@ function pickRunningTerminalSessionForBootstrap( export function ThreadTerminalRouteScreen() { const router = useRouter(); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); + const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); + const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; - const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const { state: workspaceState } = useWorkspaceState(); const params = useLocalSearchParams<{ environmentId?: string | string[]; threadId?: string | string[]; @@ -174,6 +175,8 @@ export function ThreadTerminalRouteScreen() { ? EnvironmentId.make(routeEnvironmentIdRaw) : null; const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null; + const environment = useEnvironmentPresentation(routeEnvironmentId); + const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const requestedTerminalId = firstRouteParam(params.terminalId); const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; const cachedFontSize = getCachedTerminalFontSize(); @@ -189,6 +192,47 @@ export function ThreadTerminalRouteScreen() { environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); + const runningSession = useMemo( + () => pickRunningTerminalSessionForBootstrap(knownSessions), + [knownSessions], + ); + const activeKnownSession = useMemo( + () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, + [knownSessions, terminalId], + ); + const launchTarget = useMemo( + () => + selectedThread + ? { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + terminalId, + } + : null, + [selectedThread, terminalId], + ); + const launchTargetKey = launchTarget + ? `${launchTarget.environmentId}:${launchTarget.threadId}:${launchTarget.terminalId}` + : null; + const [pendingLaunchEntry, setPendingLaunchEntry] = useState<{ + readonly key: string | null; + readonly launch: PendingTerminalLaunch | null; + }>(() => ({ + key: launchTargetKey, + launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), + })); + const pendingLaunch = + pendingLaunchEntry.key === launchTargetKey ? pendingLaunchEntry.launch : null; + const hasResolvedPendingLaunch = pendingLaunchEntry.key === launchTargetKey; + const [initialAttachGridEntry, setInitialAttachGridEntry] = useState(() => ({ + key: launchTargetKey, + size: cachedRouteGridSize ?? { + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }, + })); + const initialAttachGridSize = + initialAttachGridEntry.key === launchTargetKey ? initialAttachGridEntry.size : null; const [lastGridSize, setLastGridSize] = useState( cachedRouteGridSize ?? { cols: DEFAULT_TERMINAL_COLS, @@ -198,11 +242,10 @@ export function ThreadTerminalRouteScreen() { const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); - const hasOpenedRef = useRef(false); const bufferReplayTimerRef = useRef | null>(null); - const attachStreamLogCountRef = useRef(0); const firstNonEmptyBufferLoggedRef = useRef(false); const lastBufferReplayKeyRef = useRef(null); + const sentInitialInputKeyRef = useRef(null); const [readyBufferReplayKey, setReadyBufferReplayKey] = useState(null); const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( cachedFontSize !== null, @@ -216,12 +259,78 @@ export function ThreadTerminalRouteScreen() { terminalId, value: null, }); - const target = useTerminalSessionTarget({ + const shouldRedirectToRunningTerminal = + requestedTerminalId === null && + runningSession !== null && + runningSession.target.terminalId !== terminalId; + const launchLocationCandidate = useMemo(() => { + if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + return null; + } + if (pendingLaunch) { + return { + cwd: pendingLaunch.cwd, + worktreePath: pendingLaunch.worktreePath, + }; + } + return resolveTerminalOpenLocation({ + terminalLocation: activeKnownSession?.state.summary ?? null, + activeSessionLocation: activeKnownSession?.state.summary ?? null, + workspaceRoot: selectedThreadProject.workspaceRoot, + threadShellWorktreePath: selectedThread.worktreePath ?? null, + threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, + }); + }, [ + activeKnownSession?.state.summary, + pendingLaunch, + selectedThread, + selectedThreadDetail?.worktreePath, + selectedThreadProject?.workspaceRoot, + ]); + const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({ + key: launchTargetKey, + location: launchLocationCandidate, + })); + const launchLocation = + initialLaunchLocationEntry.key === launchTargetKey ? initialLaunchLocationEntry.location : null; + const terminalAttachInput = useMemo( + () => + selectedThread !== null && + launchLocation !== null && + hasResolvedPendingLaunch && + initialAttachGridSize !== null && + hasResolvedFontPreference && + hasMeasuredSurface && + isEnvironmentReady && + !shouldRedirectToRunningTerminal + ? { + threadId: selectedThread.id, + terminalId, + cwd: launchLocation.cwd, + worktreePath: launchLocation.worktreePath, + cols: initialAttachGridSize.cols, + rows: initialAttachGridSize.rows, + ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}), + ...(pendingLaunch ? { restartIfNotRunning: true } : {}), + } + : null, + [ + hasMeasuredSurface, + hasResolvedFontPreference, + hasResolvedPendingLaunch, + initialAttachGridSize, + isEnvironmentReady, + launchLocation, + pendingLaunch, + selectedThread, + shouldRedirectToRunningTerminal, + terminalId, + ], + ); + const terminal = useAttachedTerminalSession({ environmentId: selectedThread?.environmentId ?? null, - threadId: selectedThread?.id ?? null, - terminalId, + terminal: terminalAttachInput, }); - const terminal = useTerminalSession(target); const terminalKey = selectedThread ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` : terminalId; @@ -293,23 +402,6 @@ export function ThreadTerminalRouteScreen() { () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), [selectedEnvironmentConnection?.environmentLabel], ); - const runningSession = useMemo( - () => pickRunningTerminalSessionForBootstrap(knownSessions), - [knownSessions], - ); - const activeKnownSession = useMemo( - () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, - [knownSessions, terminalId], - ); - - const terminalAttachLaunchHintsRef = useRef({ - terminalSummary: terminal.summary, - activeKnownSummary: activeKnownSession?.state.summary ?? null, - }); - terminalAttachLaunchHintsRef.current = { - terminalSummary: terminal.summary, - activeKnownSummary: activeKnownSession?.state.summary ?? null, - }; const terminalTheme = getPierreTerminalTheme(appearanceScheme); const pendingModifier = @@ -406,145 +498,88 @@ export function ThreadTerminalRouteScreen() { ], ); - const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => { - const n = ++attachStreamLogCountRef.current; - if (event.type === "output" && n > 32 && n % 64 !== 0) { + useEffect(() => { + if (pendingLaunchEntry.key === launchTargetKey) { return; } - if (event.type === "snapshot") { - terminalDebugLog("attach:stream", { - n, - type: event.type, - status: event.snapshot.status, - historyLen: event.snapshot.history.length, - cwd: event.snapshot.cwd, - }); + setPendingLaunchEntry({ + key: launchTargetKey, + launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), + }); + }, [launchTarget, launchTargetKey, pendingLaunchEntry.key]); + + useEffect(() => { + if (initialAttachGridEntry.key === launchTargetKey) { return; } - if (event.type === "output") { - terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length }); + setInitialAttachGridEntry({ + key: launchTargetKey, + size: cachedRouteGridSize ?? { + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }, + }); + }, [cachedRouteGridSize, initialAttachGridEntry.key, launchTargetKey]); + + useEffect(() => { + if ( + initialLaunchLocationEntry.key === launchTargetKey && + initialLaunchLocationEntry.location !== null + ) { return; } - terminalDebugLog("attach:stream", { n, type: event.type }); - }, []); - - const attachTerminal = useCallback(() => { - if (!selectedThread || !selectedThreadProject?.workspaceRoot) { - terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" }); - return null; + if (initialLaunchLocationEntry.key === launchTargetKey && launchLocationCandidate === null) { + return; } + setInitialLaunchLocationEntry({ + key: launchTargetKey, + location: launchLocationCandidate, + }); + }, [ + initialLaunchLocationEntry.key, + initialLaunchLocationEntry.location, + launchLocationCandidate, + launchTargetKey, + ]); - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - terminalDebugLog("attach:abort", { - reason: "no-environment-client", - environmentId: selectedThread.environmentId, - }); - return null; + useEffect(() => { + if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { + return; } + router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId)); + }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]); - const pendingLaunchTarget = { + useEffect(() => { + const initialInput = pendingLaunch?.initialInput; + if ( + !initialInput || + !selectedThread || + terminal.version === 0 || + sentInitialInputKeyRef.current === launchTargetKey + ) { + return; + } + sentInitialInputKeyRef.current = launchTargetKey; + void writeTerminal({ environmentId: selectedThread.environmentId, - threadId: selectedThread.id, - terminalId, - }; - const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget); - let initialInputSent = false; - - try { - const launchLocation = pendingLaunch - ? { - cwd: pendingLaunch.cwd, - worktreePath: pendingLaunch.worktreePath, - } - : resolveTerminalOpenLocation({ - terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary, - activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary, - workspaceRoot: selectedThreadProject.workspaceRoot, - threadShellWorktreePath: selectedThread.worktreePath ?? null, - threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, - }); - - terminalDebugLog("attach:start", { - terminalId, + input: { threadId: selectedThread.id, - cols: lastGridSize.cols, - rows: lastGridSize.rows, - cwd: launchLocation.cwd, - worktreePath: launchLocation.worktreePath, - }); - - return attachTerminalSession({ - environmentId: selectedThread.environmentId, - client, - terminal: { - threadId: selectedThread.id, - terminalId, - cwd: launchLocation.cwd, - worktreePath: launchLocation.worktreePath, - cols: lastGridSize.cols, - rows: lastGridSize.rows, - env: pendingLaunch?.env, - ...(pendingLaunch ? { restartIfNotRunning: true } : {}), - }, - onEvent: logAttachStreamEvent, - onSnapshot: () => { - if (!pendingLaunch?.initialInput || initialInputSent) { - return; - } - - initialInputSent = true; - void client.terminal.write({ - threadId: selectedThread.id, - terminalId, - data: pendingLaunch.initialInput, - }); - }, - }); - } catch (error) { - terminalDebugLog("attach:error", { - message: error instanceof Error ? error.message : String(error), - }); - if (pendingLaunch) { - stagePendingTerminalLaunch({ - target: pendingLaunchTarget, - launch: pendingLaunch, - }); - } - - throw error; - } + terminalId, + data: initialInput, + }, + }); }, [ - lastGridSize.cols, - lastGridSize.rows, - logAttachStreamEvent, - selectedThreadDetail?.worktreePath, + launchTargetKey, + pendingLaunch?.initialInput, selectedThread, - selectedThreadProject?.workspaceRoot, + terminal.version, terminalId, + writeTerminal, ]); - const attachTerminalRef = useRef(attachTerminal); - attachTerminalRef.current = attachTerminal; - const selectedThreadRef = useRef(selectedThread); - selectedThreadRef.current = selectedThread; - const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject); - selectedThreadProjectBootstrapRef.current = selectedThreadProject; - const runningSessionRef = useRef(runningSession); - runningSessionRef.current = runningSession; - const terminalBootstrapRef = useRef({ - status: terminal.status, - bufferLen: terminal.buffer.length, - }); - terminalBootstrapRef.current = { - status: terminal.status, - bufferLen: terminal.buffer.length, - }; - useEffect(() => { - hasOpenedRef.current = false; - attachStreamLogCountRef.current = 0; firstNonEmptyBufferLoggedRef.current = false; + sentInitialInputKeyRef.current = null; }, [terminalKey]); const clearBufferReplayTimer = useCallback(() => { @@ -638,99 +673,22 @@ export function ThreadTerminalRouteScreen() { }); }, [fontSize, hasResolvedFontPreference]); - // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change. - // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when - // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup - // → detach immediately after the first snapshot. - useEffect(() => { - if (!hasResolvedFontPreference || !hasMeasuredSurface) { - return; - } - - const thread = selectedThreadRef.current; - const project = selectedThreadProjectBootstrapRef.current; - const running = runningSessionRef.current; - const termSnap = terminalBootstrapRef.current; - - const bootstrapAction = resolveTerminalRouteBootstrap({ - hasThread: thread !== null, - hasWorkspaceRoot: Boolean(project?.workspaceRoot), - hasOpened: hasOpenedRef.current, - requestedTerminalId, - currentTerminalId: terminalId, - runningTerminalId: running?.target.terminalId ?? null, - currentTerminalStatus: termSnap.status, - // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`; - // treating summary as "hydrated" skipped attach while status was running → empty surface. - hasCurrentTerminalHydration: termSnap.bufferLen > 0, - }); - if (bootstrapAction.kind !== "idle") { - terminalDebugLog("bootstrap:action", { - kind: bootstrapAction.kind, - hasOpenedBefore: hasOpenedRef.current, - hasHydration: termSnap.bufferLen > 0, - terminalStatus: termSnap.status, - bufLen: termSnap.bufferLen, - }); - } - if (bootstrapAction.kind === "idle" || !thread) { - return; - } - - if (bootstrapAction.kind === "redirect") { - router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId)); - return; - } - - hasOpenedRef.current = true; - try { - const detach = attachTerminalRef.current(); - terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) }); - if (!detach) { - hasOpenedRef.current = false; - return; - } - return () => { - detach(); - hasOpenedRef.current = false; - terminalDebugLog("bootstrap:unsubscribe"); - }; - } catch (error) { - hasOpenedRef.current = false; - terminalDebugLog("bootstrap:attach-threw", { - message: error instanceof Error ? error.message : String(error), - }); - return; - } - }, [ - hasMeasuredSurface, - hasResolvedFontPreference, - requestedTerminalId, - router, - selectedThread?.environmentId, - selectedThread?.id, - selectedThreadProject?.workspaceRoot, - terminalId, - ]); - const writeInput = useCallback( (data: string) => { if (!selectedThread || !isRunning) { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - void client.terminal.write({ - threadId: selectedThread.id, - terminalId, - data, + void writeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + data, + }, }); }, - [isRunning, selectedThread, terminalId], + [isRunning, selectedThread, terminalId, writeTerminal], ); const handleInput = useCallback( @@ -782,16 +740,14 @@ export function ThreadTerminalRouteScreen() { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - void client.terminal.resize({ - threadId: selectedThread.id, - terminalId, - cols: size.cols, - rows: size.rows, + void resizeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + cols: size.cols, + rows: size.rows, + }, }); }, [ @@ -802,6 +758,7 @@ export function ThreadTerminalRouteScreen() { readyBufferReplayKey, routeEnvironmentId, routeThreadId, + resizeTerminal, scheduleBufferReplayReady, selectedThread, terminalId, @@ -855,17 +812,15 @@ export function ThreadTerminalRouteScreen() { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - setPendingModifierState({ terminalId, value: null }); - void client.terminal.clear({ - threadId: selectedThread.id, - terminalId, + void clearTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + }, }); - }, [selectedThread, terminalId]); + }, [clearTerminal, selectedThread, terminalId]); const handleToolbarActionPress = useCallback( (action: TerminalToolbarAction) => { @@ -905,9 +860,14 @@ export function ThreadTerminalRouteScreen() { const handleShowKeyboard = useCallback(() => { setKeyboardFocusRequest((current) => current + 1); }, []); + const handleRetryEnvironment = useCallback(() => { + if (routeEnvironmentId !== null) { + void retryEnvironment(routeEnvironmentId); + } + }, [retryEnvironment, routeEnvironmentId]); if (!selectedThread) { - if (isLoadingSavedConnection) { + if (workspaceState.isLoadingConnections) { return ; } @@ -932,6 +892,10 @@ export function ThreadTerminalRouteScreen() { ); } + if (!environment.isReady && environment.presentation === null) { + return ; + } + return ( <> @@ -969,7 +933,7 @@ export function ThreadTerminalRouteScreen() { style={{ color: terminalTheme.mutedForeground, fontFamily: "Menlo", - fontSize: 11, + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, lineHeight: 14, }} > @@ -980,152 +944,178 @@ export function ThreadTerminalRouteScreen() { }} /> - - - - {getTerminalStatusLabel({ - status: terminal.status, - hasRunningSubprocess: terminal.hasRunningSubprocess, - })} - - - Text size - - {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} - + {isEnvironmentReady ? ( + + + + {getTerminalStatusLabel({ + status: terminal.status, + hasRunningSubprocess: terminal.hasRunningSubprocess, + })} + + + Text size + + {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + = MAX_TERMINAL_FONT_SIZE} + discoverabilityLabel="Increase terminal text size" + onPress={handleIncreaseFontSize} + > + {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + + {terminalMenuSessions.map((session) => ( + handleSelectTerminal(session.terminalId)} + subtitle={[ + getTerminalStatusLabel({ status: session.status }), + basename(session.cwd), + ] + .filter(Boolean) + .join(" · ")} + > + {session.displayLabel} + + ))} = MAX_TERMINAL_FONT_SIZE} - discoverabilityLabel="Increase terminal text size" - onPress={handleIncreaseFontSize} + icon="plus" + onPress={handleOpenNewTerminal} + subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} > - {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + Open new terminal - {terminalMenuSessions.map((session) => ( - handleSelectTerminal(session.terminalId)} - subtitle={[getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] - .filter(Boolean) - .join(" · ")} - > - {session.displayLabel} - - ))} - - Open new terminal - - - + + ) : null} - - - + ) : ( + <> + + + - {isAccessoryVisible ? ( - - - - + - {terminalToolbarActions.map((action) => { - const active = - action.kind === "modifier" && pendingModifier === action.modifier; - - return ( - 1 ? 56 : 44} - onPress={() => handleToolbarActionPress(action)} - showChevron={false} - textTransform={ - action.kind === "modifier" || action.kind === "clear" - ? "uppercase" - : "none" - } - /> - ); - })} - - - - - - ) : !keyboardState.isVisible ? ( - ({ - bottom: 16, - borderRadius: 28, - opacity: pressed ? 0.72 : 1, - position: "absolute", - right: 16, - })} - > - - - - - ) : null} + + + {terminalToolbarActions.map((action) => { + const active = + action.kind === "modifier" && pendingModifier === action.modifier; + + return ( + 1 ? 56 : 44} + onPress={() => handleToolbarActionPress(action)} + showChevron={false} + textTransform={ + action.kind === "modifier" || action.kind === "clear" + ? "uppercase" + : "none" + } + /> + ); + })} + + + + + + ) : !keyboardState.isVisible ? ( + ({ + bottom: 16, + borderRadius: 28, + opacity: pressed ? 0.72 : 1, + position: "absolute", + right: 16, + })} + > + + + + + ) : null} + + )} ); diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts index c7418a52533..5cb37cbb0a9 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts @@ -43,10 +43,23 @@ describe("resolveNativeTerminalSurfaceView", () => { it("returns null when the view manager cannot be required", async () => { setExpoViewConfigAvailable(); + const cause = new Error("boom"); expoMocks.requireNativeView.mockImplementation(() => { - throw new Error("boom"); + throw cause; }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); + + expect(resolveNativeTerminalSurfaceView()).toBeNull(); expect(resolveNativeTerminalSurfaceView()).toBeNull(); + expect(expoMocks.requireNativeView).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NativeViewResolutionError", + nativeModuleName: "T3TerminalSurface", + cause, + }), + ); + expect(consoleError).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index c4686a38b4e..e5b1f630073 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -2,6 +2,8 @@ import type { ComponentType } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView } from "expo"; +import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; + const NATIVE_TERMINAL_MODULE_NAME = "T3TerminalSurface"; interface ExpoGlobalWithViewConfig { @@ -33,6 +35,7 @@ export interface NativeTerminalSurfaceProps extends ViewProps { } let cachedNativeTerminalSurfaceView: ComponentType | undefined; +let nativeTerminalSurfaceViewResolutionFailed = false; function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( @@ -45,6 +48,10 @@ export function resolveNativeTerminalSurfaceView(): ComponentType( NATIVE_TERMINAL_MODULE_NAME, ); - } catch { + } catch (cause) { + nativeTerminalSurfaceViewResolutionFailed = true; + console.error( + new NativeViewResolutionError({ + nativeModuleName: NATIVE_TERMINAL_MODULE_NAME, + cause, + }), + ); return null; } diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 048ce2ac409..48c87e18dd4 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 0e0e80ef5d9..29374bdda6d 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -1,4 +1,4 @@ -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import * as Arr from "effect/Array"; diff --git a/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts b/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts new file mode 100644 index 00000000000..871a28d8528 --- /dev/null +++ b/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts @@ -0,0 +1,40 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadTerminalAttachInput, + threadTerminalSubscriptionKey, + type ThreadTerminalSubscriptionIdentity, +} from "./threadTerminalPanelModel"; + +const identity: ThreadTerminalSubscriptionIdentity = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: "default", + cwd: "/repo", + worktreePath: "/repo", +}; + +describe("threadTerminalSubscriptionKey", () => { + it("does not include mutable terminal dimensions", () => { + const initialAttach = buildThreadTerminalAttachInput(identity, { cols: 80, rows: 24 }); + const resizedAttach = buildThreadTerminalAttachInput(identity, { cols: 132, rows: 40 }); + + expect(initialAttach).not.toEqual(resizedAttach); + expect(threadTerminalSubscriptionKey({ ...identity, ...initialAttach })).toBe( + threadTerminalSubscriptionKey({ ...identity, ...resizedAttach }), + ); + }); + + it.each([ + ["environment", { environmentId: EnvironmentId.make("env-2") }], + ["thread", { threadId: ThreadId.make("thread-2") }], + ["terminal", { terminalId: "term-2" }], + ["cwd", { cwd: "/repo/packages/app" }], + ["worktree", { worktreePath: "/repo/worktrees/feature" }], + ])("changes when the %s identity changes", (_label, update) => { + expect(threadTerminalSubscriptionKey({ ...identity, ...update })).not.toBe( + threadTerminalSubscriptionKey(identity), + ); + }); +}); diff --git a/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts b/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts new file mode 100644 index 00000000000..9f1d032d264 --- /dev/null +++ b/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts @@ -0,0 +1,40 @@ +import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; + +export interface ThreadTerminalSubscriptionIdentity { + readonly environmentId: EnvironmentId; + readonly threadId: TerminalAttachInput["threadId"]; + readonly terminalId: TerminalAttachInput["terminalId"]; + readonly cwd: string; + readonly worktreePath: string | null; +} + +export interface TerminalGridSize { + readonly cols: number; + readonly rows: number; +} + +export function threadTerminalSubscriptionKey( + identity: ThreadTerminalSubscriptionIdentity, +): string { + return JSON.stringify([ + identity.environmentId, + identity.threadId, + identity.terminalId, + identity.cwd, + identity.worktreePath, + ]); +} + +export function buildThreadTerminalAttachInput( + identity: ThreadTerminalSubscriptionIdentity, + gridSize: TerminalGridSize, +): TerminalAttachInput { + return { + threadId: identity.threadId, + terminalId: identity.terminalId, + cwd: identity.cwd, + worktreePath: identity.worktreePath, + cols: gridSize.cols, + rows: gridSize.rows, + }; +} diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 1b652a139cf..8b6fe078088 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -7,6 +7,7 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; export type ComposerCommandItem = | { @@ -156,14 +157,22 @@ const CommandRow = memo(function CommandRow(props: { ) : null} {props.item.label} {props.item.description ? ( - + {props.item.description} ) : null} @@ -182,7 +191,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( {label ? ( {label} @@ -206,7 +215,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : ( - + {emptyText(props.triggerKind, props.isLoading)} diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 96fce3e3ccd..93d929e5961 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,11 +1,12 @@ import * as Haptics from "expo-haptics"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useRef } from "react"; -import { ActivityIndicator, Linking, Pressable, View } from "react-native"; +import { ActivityIndicator, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useThemeColor } from "../../lib/useThemeColor"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; @@ -30,7 +31,7 @@ export function GitActionProgressOverlay(props: { const handlePress = useCallback(() => { if (progress.prUrl) { - void Linking.openURL(progress.prUrl); + void tryOpenExternalUrl(progress.prUrl, "pull-request"); return; } if (progress.phase === "success" || progress.phase === "error") { @@ -73,12 +74,12 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { {progress.label ? ( - + {progress.label} ) : null} {progress.description ? ( - + {progress.description} ) : null} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index de95e3645bd..ce24198f5e2 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,11 +1,15 @@ import { Stack, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { InteractionManager, View, useColorScheme } from "react-native"; +import { Alert, InteractionManager, View, useColorScheme } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; -import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { @@ -19,27 +23,19 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; +import { + applyProviderOptionMenuEvent, + buildProviderOptionMenuActions, + providerOptionsConfigurationLabel, + resolveProviderOptionDescriptors, +} from "../../lib/providerOptions"; import { buildThreadRoutePath } from "../../lib/routes"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; -import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; +import { scopedProjectKey } from "../../lib/scopedEntities"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; +import { useProjects } from "../../state/entities"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; -import { useProjectActions } from "./use-project-actions"; - -function withModelSelectionOption( - selection: ModelSelection, - id: string, - value: string | boolean | undefined, -): ModelSelection { - const options = (selection.options ?? []).filter((option) => option.id !== id); - return { - ...selection, - options: value === undefined ? options : [...options, { id, value }], - }; -} - -function formatTitleCase(value: string): string { - return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; -} +import { useCreateProjectThread } from "./use-project-actions"; function formatWorkspaceLabel(input: { readonly workspaceMode: string; @@ -59,8 +55,8 @@ export function NewTaskDraftScreen(props: { readonly projectId?: string; }; }) { - const { projects } = useRemoteCatalog(); - const { onCreateThreadWithOptions } = useProjectActions(); + const projects = useProjects(); + const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); const router = useRouter(); const insets = useSafeAreaInsets(); @@ -69,6 +65,7 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const promptInputRef = useRef(null); + const loadedBranchesProjectKeyRef = useRef(null); const borderColor = useThemeColor("--color-border"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; @@ -84,6 +81,12 @@ export function NewTaskDraftScreen(props: { ) ?? null; if (directProject) { + if ( + selectedProject?.environmentId === directProject.environmentId && + selectedProject.id === directProject.id + ) { + return; + } setProject(directProject); return; } @@ -111,10 +114,16 @@ export function NewTaskDraftScreen(props: { useEffect(() => { if (!selectedProject) { + loadedBranchesProjectKeyRef.current = null; + return; + } + const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`; + if (loadedBranchesProjectKeyRef.current === projectKey) { return; } + loadedBranchesProjectKeyRef.current = projectKey; void flow.loadBranches(); - }, [flow, selectedProject]); + }, [flow.loadBranches, selectedProject]); useEffect(() => { if (!selectedProject) { @@ -169,39 +178,18 @@ export function NewTaskDraftScreen(props: { })), [flow.providerGroups, flow.selectedModel], ); + const providerOptionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: flow.selectedModelOption?.capabilities, + selections: flow.selectedModel?.options, + }), + [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], + ); const optionsMenuActions = useMemo( () => [ - { - id: "options-effort", - title: "Effort", - subtitle: `${flow.effort.charAt(0).toUpperCase()}${flow.effort.slice(1)}`, - subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ - id: `options:effort:${level}`, - title: `${level}${level === "high" ? " (default)" : ""}`, - state: flow.effort === level ? ("on" as const) : undefined, - })), - }, - { - id: "options-fast-mode", - title: "Fast Mode", - subtitle: flow.fastMode ? "On" : "Off", - subactions: ([false, true] as const).map((value) => ({ - id: `options:fast-mode:${value ? "on" : "off"}`, - title: value ? "On" : "Off", - state: flow.fastMode === value ? ("on" as const) : undefined, - })), - }, - { - id: "options-context-window", - title: "Context Window", - subtitle: flow.contextWindow, - subactions: (["200k", "1M"] as const).map((value) => ({ - id: `options:context-window:${value}`, - title: `${value}${value === "1M" ? " (default)" : ""}`, - state: flow.contextWindow === value ? ("on" as const) : undefined, - })), - }, + ...buildProviderOptionMenuActions(providerOptionDescriptors), { id: "options-runtime", title: "Runtime", @@ -241,7 +229,7 @@ export function NewTaskDraftScreen(props: { }), }, ], - [flow.contextWindow, flow.effort, flow.fastMode, flow.interactionMode, flow.runtimeMode], + [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors], ); const workspaceMenuActions = useMemo(() => { @@ -302,14 +290,10 @@ export function NewTaskDraftScreen(props: { flow.availableBranches.find((branch) => branch.current)?.name ?? flow.availableBranches.find((branch) => branch.isDefault)?.name ?? null; - const configurationLabel = useMemo(() => { - const parts = [ - formatTitleCase(flow.effort), - flow.fastMode ? "Fast" : null, - flow.contextWindow !== "1M" ? flow.contextWindow : null, - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(" · ") : "Configuration"; - }, [flow.contextWindow, flow.effort, flow.fastMode]); + const configurationLabel = useMemo( + () => providerOptionsConfigurationLabel(providerOptionDescriptors), + [providerOptionDescriptors], + ); const workspaceLabel = useMemo( () => formatWorkspaceLabel({ @@ -323,11 +307,7 @@ export function NewTaskDraftScreen(props: { if (!event.startsWith("model:")) { return; } - // Defer state update so the native menu dismiss animation completes - // before re-rendering the menu actions (prevents submenu jump). - setTimeout(() => { - flow.setSelectedModelKey(event.slice("model:".length)); - }, 150); + flow.setSelectedModelKey(event.slice("model:".length)); } function handleEnvironmentMenuAction(event: string) { @@ -338,16 +318,9 @@ export function NewTaskDraftScreen(props: { } function handleOptionsMenuAction(event: string) { - if (event.startsWith("options:effort:")) { - flow.setEffort(event.slice("options:effort:".length) as typeof flow.effort); - return; - } - if (event.startsWith("options:fast-mode:")) { - flow.setFastMode(event.endsWith(":on")); - return; - } - if (event.startsWith("options:context-window:")) { - flow.setContextWindow(event.slice("options:context-window:".length)); + const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); + if (providerOptions) { + flow.setSelectedModelOptions(providerOptions); return; } if (event.startsWith("options:runtime:")) { @@ -404,53 +377,59 @@ export function NewTaskDraftScreen(props: { ); async function handleStart(): Promise { + const selectedProject = flow.selectedProject; + if (!selectedProject) { + return; + } + const draft = getComposerDraftSnapshot( + `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`, + ); + const modelSelection = draft.modelSelection ?? flow.selectedModel; + const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; + const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; + const selectedWorktreePath = + draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; + const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; + const interactionMode = draft.interactionMode ?? flow.interactionMode; + const initialMessageText = draft.text.trim(); + if ( - !flow.selectedProject || - !flow.selectedModel || - flow.prompt.trim().length === 0 || + !modelSelection || + initialMessageText.length === 0 || flow.submitting || - (flow.workspaceMode === "worktree" && !flow.selectedBranchName) + (workspaceMode === "worktree" && !selectedBranchName) ) { return; } flow.setSubmitting(true); - try { - const modelWithOptions: ModelSelection = - flow.selectedModelOption?.providerDriver === "claudeAgent" - ? withModelSelectionOption( - withModelSelectionOption( - withModelSelectionOption(flow.selectedModel, "effort", flow.effort), - "fastMode", - flow.fastMode || undefined, - ), - "contextWindow", - flow.contextWindow, - ) - : flow.selectedModelOption?.providerDriver === "codex" - ? withModelSelectionOption(flow.selectedModel, "fastMode", flow.fastMode || undefined) - : flow.selectedModel; - - const createdThread = await onCreateThreadWithOptions({ - project: flow.selectedProject, - modelSelection: modelWithOptions, - envMode: flow.workspaceMode, - branch: flow.selectedBranchName, - worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, - runtimeMode: flow.runtimeMode, - interactionMode: flow.interactionMode, - initialMessageText: flow.prompt.trim(), - initialAttachments: flow.attachments, - }); - - if (createdThread) { - flow.setPrompt(""); - flow.clearAttachments(); - router.replace(buildThreadRoutePath(createdThread)); + const result = await createProjectThread({ + project: selectedProject, + modelSelection, + envMode: workspaceMode, + branch: selectedBranchName, + worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, + runtimeMode, + interactionMode, + initialMessageText, + initialAttachments: draft.attachments, + }); + flow.setSubmitting(false); + + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not start task", + error instanceof Error ? error.message : "The task could not be started.", + ); } - } finally { - flow.setSubmitting(false); + return; } + + flow.setPrompt(""); + flow.clearAttachments(); + router.replace(buildThreadRoutePath(result.value)); } if (!selectedProject) { @@ -478,7 +457,7 @@ export function NewTaskDraftScreen(props: { onPasteImages={(uris) => void handleNativePasteImages(uris)} placeholder={`Describe a coding task in ${selectedProject.title}`} style={{ flex: 1, minHeight: 0 }} - textStyle={{ fontSize: 18, lineHeight: 28 }} + textStyle={MOBILE_TYPOGRAPHY.composer} /> diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index eb9e929ed14..0617ef1cbcf 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -10,13 +10,13 @@ export interface PendingApprovalCardProps { readonly onRespond: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; } export function PendingApprovalCard(props: PendingApprovalCardProps) { return ( - + Approval needed diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index c42a7ff34e0..c9e01777214 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -20,13 +20,13 @@ export interface PendingUserInputCardProps { questionId: string, customAnswer: string, ) => void; - readonly onSubmit: () => Promise; + readonly onSubmit: () => Promise; } export function PendingUserInputCard(props: PendingUserInputCardProps) { return ( - + User input needed @@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.header} - + {question.question} @@ -65,7 +65,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { > ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7d38353879e..75991cae885 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,8 +1,9 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentId, + MessageId, ModelSelection, - OrchestrationThread, + OrchestrationThreadShell, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, @@ -15,7 +16,14 @@ import { } from "@t3tools/shared/composerTrigger"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { Image, Pressable, useColorScheme, View, type ViewStyle } from "react-native"; +import { + ActivityIndicator, + Image, + Pressable, + useColorScheme, + View, + type ViewStyle, +} from "react-native"; import ImageViewing from "react-native-image-viewing"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -36,6 +44,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { insertRankedSearchResult, @@ -43,11 +52,12 @@ import { scoreQueryMatch, } from "@t3tools/shared/searchRanking"; import { - getModelSelectionBooleanOptionValue, - getModelSelectionStringOptionValue, -} from "@t3tools/shared/model"; + applyProviderOptionMenuEvent, + buildProviderOptionMenuActions, + providerOptionsConfigurationLabel, + resolveProviderOptionDescriptors, +} from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; -import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; /** @@ -68,7 +78,9 @@ export interface ThreadComposerProps { readonly placeholder: string; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; - readonly selectedThread: OrchestrationThread; + readonly connectionError: string | null; + readonly environmentLabel: string | null; + readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly activeThreadBusy: boolean; @@ -79,11 +91,12 @@ export interface ThreadComposerProps { readonly onPickDraftImages: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; - readonly onStopThread: () => Promise; - readonly onSendMessage: () => void; - readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise; - readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise; - readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise; + readonly onStopThread: () => void; + readonly onSendMessage: () => Promise; + readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; + readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; + readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; + readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; } @@ -126,21 +139,67 @@ function ComposerSurface(props: { ); } -function withModelSelectionOption( - selection: ModelSelection, - id: string, - value: string | boolean | undefined, -): ModelSelection { - const options = (selection.options ?? []).filter((option) => option.id !== id); - return { - ...selection, - options: value === undefined ? options : [...options, { id, value }], - }; +function composerConnectionStatus(input: { + readonly connectionError: string | null; + readonly connectionState: RemoteClientConnectionState; + readonly environmentLabel: string | null; +}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null { + const environmentLabel = input.environmentLabel ?? "Environment"; + + switch (input.connectionState) { + case "connecting": + case "reconnecting": + return { + kind: "reconnecting", + label: + input.connectionError === null + ? `Reconnecting to ${environmentLabel}...` + : `Failed to connect. Retrying ${environmentLabel}...`, + }; + case "offline": + return { kind: "unavailable", label: "You are offline" }; + case "error": + return { + kind: "unavailable", + label: input.connectionError + ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` + : `Failed to connect to ${environmentLabel}`, + }; + case "available": + return { kind: "unavailable", label: `${environmentLabel} is not connected` }; + case "connected": + return null; + } } -function formatTitleCase(value: string): string { - return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; -} +const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { + readonly onPress: () => void; + readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string }; +}) { + const isReconnecting = props.status.kind === "reconnecting"; + + return ( + + + {isReconnecting ? ( + + ) : ( + + )} + + {props.status.label} + + + + ); +}); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const isDarkMode = useColorScheme() === "dark"; @@ -154,7 +213,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const isExpanded = isFocused; - const canSend = props.connectionState === "ready" && hasContent; + const canSend = hasContent; const onPressImage = useCallback( (uri: string) => { @@ -182,13 +241,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [onExpandedChange]); const showStopAction = props.selectedThread.session?.status === "running" || - props.selectedThread.session?.status === "starting" || - props.queueCount > 0; + props.selectedThread.session?.status === "starting"; - const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send"; + const sendLabel = + props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 + ? "Queue" + : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; + const connectionStatus = composerConnectionStatus({ + connectionError: props.connectionError, + connectionState: props.connectionState, + environmentLabel: props.environmentLabel, + }); const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; const selectedProviderStatus = useMemo(() => { @@ -200,18 +266,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); - // Extract current model options (effort, fastMode, contextWindow) - const selectedProviderDriver = selectedProviderStatus?.driver ?? null; - const currentEffort = - selectedProviderDriver === "claudeAgent" - ? (getModelSelectionStringOptionValue(currentModelSelection, "effort") ?? "high") - : "high"; - const currentFastMode = - getModelSelectionBooleanOptionValue(currentModelSelection, "fastMode") ?? false; - const currentContextWindow = - selectedProviderDriver === "claudeAgent" - ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") - : "1M"; // ── Trigger detection ──────────────────────────────────── const [composerSelection, setComposerSelection] = useState(() => ({ start: props.draftMessage.length, @@ -394,8 +448,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(() => { - onSendMessage(); - inputRef.current?.blur(); + void onSendMessage(); }, [onSendMessage]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { @@ -413,7 +466,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); - void onUpdateInteractionMode(item.command); + onUpdateInteractionMode(item.command); return; } @@ -452,14 +505,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null; - const configurationLabel = useMemo(() => { - const parts = [ - formatTitleCase(currentEffort), - currentFastMode ? "Fast" : null, - currentContextWindow !== "1M" ? currentContextWindow : null, - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(" · ") : "Configuration"; - }, [currentContextWindow, currentEffort, currentFastMode]); + const providerOptionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: currentModelOption?.capabilities, + selections: currentModelSelection.options, + }), + [currentModelOption?.capabilities, currentModelSelection.options], + ); + const configurationLabel = useMemo( + () => providerOptionsConfigurationLabel(providerOptionDescriptors), + [providerOptionDescriptors], + ); const modelMenuActions = useMemo( () => providerGroups.map((group) => ({ @@ -486,36 +543,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // ── Options menu ───────────────────────────────────────── const optionsMenuActions = useMemo( () => [ - { - id: "options-effort", - title: "Effort", - subtitle: `${currentEffort.charAt(0).toUpperCase()}${currentEffort.slice(1)}`, - subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ - id: `options:effort:${level}`, - title: `${level}${level === "high" ? " (default)" : ""}`, - state: currentEffort === level ? ("on" as const) : undefined, - })), - }, - { - id: "options-fast-mode", - title: "Fast Mode", - subtitle: currentFastMode ? "On" : "Off", - subactions: ([false, true] as const).map((value) => ({ - id: `options:fast-mode:${value ? "on" : "off"}`, - title: value ? "On" : "Off", - state: currentFastMode === value ? ("on" as const) : undefined, - })), - }, - { - id: "options-context-window", - title: "Context Window", - subtitle: currentContextWindow, - subactions: (["200k", "1M"] as const).map((value) => ({ - id: `options:context-window:${value}`, - title: `${value}${value === "1M" ? " (default)" : ""}`, - state: currentContextWindow === value ? ("on" as const) : undefined, - })), - }, + ...buildProviderOptionMenuActions(providerOptionDescriptors), { id: "options-runtime", title: "Runtime", @@ -555,13 +583,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), }, ], - [ - currentEffort, - currentFastMode, - currentContextWindow, - currentRuntimeMode, - currentInteractionMode, - ], + [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], ); // ── Menu handlers ──────────────────────────────────────── @@ -572,51 +594,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const modelKey = event.slice("model:".length); const option = modelOptions.find((o) => o.key === modelKey); if (option) { - void props.onUpdateModelSelection(option.selection); + props.onUpdateModelSelection(option.selection); } } function handleOptionsMenuAction(event: string) { - if (event.startsWith("options:effort:")) { - const effort = event.slice("options:effort:".length); - const updated: ModelSelection = - selectedProviderDriver === "claudeAgent" - ? withModelSelectionOption( - currentModelSelection, - "effort", - effort as typeof currentEffort, - ) - : currentModelSelection; - void props.onUpdateModelSelection(updated); - return; - } - if (event.startsWith("options:fast-mode:")) { - const fastMode = event.endsWith(":on"); - const nextFast = fastMode || undefined; - if (selectedProviderDriver === "opencode") { - return; - } - const updated = withModelSelectionOption(currentModelSelection, "fastMode", nextFast); - void props.onUpdateModelSelection(updated); - return; - } - if (event.startsWith("options:context-window:")) { - const contextWindow = event.slice("options:context-window:".length); - const updated: ModelSelection = - selectedProviderDriver === "claudeAgent" - ? withModelSelectionOption(currentModelSelection, "contextWindow", contextWindow) - : currentModelSelection; - void props.onUpdateModelSelection(updated); + const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); + if (providerOptions) { + props.onUpdateModelSelection({ + ...currentModelSelection, + options: providerOptions, + }); return; } if (event.startsWith("options:runtime:")) { const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; - void props.onUpdateRuntimeMode(runtimeMode); + props.onUpdateRuntimeMode(runtimeMode); return; } if (event.startsWith("options:interaction:")) { const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; - void props.onUpdateInteractionMode(interactionMode); + props.onUpdateInteractionMode(interactionMode); } } @@ -652,6 +650,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {connectionStatus ? ( + + ) : null} + - + +{props.draftAttachments.length - 3} @@ -755,11 +759,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} {!isExpanded ? ( showStopAction ? ( - void props.onStopThread()} - /> + ) : ( void props.onStopThread()} + onPress={props.onStopThread} showChevron={false} /> ) : null} @@ -830,8 +830,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d035f6eb909..62d1bce1157 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,8 +1,12 @@ +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; +import type { LegendListRef } from "@legendapp/list/react-native"; import type { ApprovalRequestId, EnvironmentId, + MessageId, ModelSelection, - OrchestrationThread, + OrchestrationThreadShell, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, @@ -12,8 +16,8 @@ import type { import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { View, type GestureResponderEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -23,8 +27,8 @@ import { AppText as Text } from "../../components/AppText"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import type { MobileLayoutVariant } from "../../lib/mobileLayout"; -import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; +import type { LayoutVariant } from "../../lib/layout"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, PendingUserInput, @@ -39,13 +43,14 @@ import { ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; +import type { ThreadContentPresentation } from "./threadContentPresentation"; export interface ThreadDetailScreenProps { - readonly selectedThread: OrchestrationThread; + readonly selectedThread: OrchestrationThreadShell; + readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; - readonly httpBaseUrl: string | null; - readonly bearerToken: string | null; + readonly environmentLabel: string | null; readonly selectedThreadFeed: ReadonlyArray; readonly activeWorkStartedAt: string | null; readonly activePendingApproval: PendingApproval | null; @@ -56,30 +61,30 @@ export interface ThreadDetailScreenProps { readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; - readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; + readonly connectionStateLabel: EnvironmentConnectionPhase; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; + readonly threadCwd: string | null; readonly selectedThreadQueueCount: number; readonly serverConfig: T3ServerConfig | null; - readonly layoutVariant?: MobileLayoutVariant; + readonly layoutVariant?: LayoutVariant; readonly onOpenDrawer: () => void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; - readonly onStopThread: () => Promise; - readonly onSendMessage: () => void; - readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise; - readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise; - readonly onUpdateThreadInteractionMode: ( - interactionMode: ProviderInteractionMode, - ) => Promise; + readonly onStopThread: () => void; + readonly onSendMessage: () => Promise; + readonly onReconnectEnvironment: () => void; + readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; + readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; + readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; readonly onSelectUserInputOption: ( requestId: ApprovalRequestId, questionId: string, @@ -90,7 +95,7 @@ export interface ThreadDetailScreenProps { questionId: string, customAnswer: string, ) => void; - readonly onSubmitUserInput: () => Promise; + readonly onSubmitUserInput: () => Promise; readonly showContent?: boolean; } @@ -204,25 +209,33 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; - const composerRef = useRef(null); + const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const composerEditorRef = useRef(null); + const composerOverlayRef = useRef(null); + const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); + const selectedThreadKeyRef = useRef(selectedThreadKey); + const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); + const [anchorMessageId, setAnchorMessageId] = useState(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); + const contentPresentationKind = props.contentPresentation.kind; + const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; - const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; - const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); + const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; + const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( + listRef, + composerOverlayRef, + estimatedOverlayHeight, + ); + const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const feedBottomInset = resolveThreadFeedBottomInset({ - estimatedOverlayHeight, - measuredOverlayHeight, - gap: 8, - }); const selectedProviderSkills = useMemo( () => props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) @@ -252,15 +265,67 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread [completeDrawerGesture, isSplitLayout], ); - const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => { - const nextHeight = Math.ceil(event.nativeEvent.layout.height); - setMeasuredOverlayHeight((current) => - Math.abs(current - nextHeight) > 1 ? nextHeight : current, - ); - }, []); + useLayoutEffect(() => { + selectedThreadKeyRef.current = selectedThreadKey; + }, [selectedThreadKey]); + + useEffect(() => { + setAnchorMessageId(null); + lastScrolledAnchorMessageIdRef.current = null; + freeze.set(false); + }, [freeze, selectedThreadKey]); + + useEffect(() => { + if ( + anchorMessageId === null || + lastScrolledAnchorMessageIdRef.current === anchorMessageId || + contentPresentationKind !== "ready" || + !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) + ) { + return; + } + + const targetThreadKey = selectedThreadKey; + const frame = requestAnimationFrame(() => { + if (selectedThreadKeyRef.current !== targetThreadKey) { + return; + } + lastScrolledAnchorMessageIdRef.current = anchorMessageId; + void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { + if ( + selectedThreadKeyRef.current !== targetThreadKey || + lastScrolledAnchorMessageIdRef.current !== anchorMessageId + ) { + return; + } + lastScrolledAnchorMessageIdRef.current = null; + freeze.set(false); + }); + }); + return () => cancelAnimationFrame(frame); + }, [ + anchorMessageId, + freeze, + contentPresentationKind, + selectedThreadFeed, + scrollMessageToEnd, + selectedThreadKey, + ]); + + const handleSendMessage = useCallback(async () => { + const targetThreadKey = selectedThreadKey; + const messageId = await props.onSendMessage(); + if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { + return messageId; + } + + setAnchorMessageId(messageId); + composerEditorRef.current?.blur(); + return messageId; + }, [props.onSendMessage, selectedThreadKey]); const collapseComposer = useCallback(() => { - composerRef.current?.blur(); + composerEditorRef.current?.blur(); }, []); const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { @@ -306,16 +371,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread > @@ -329,7 +398,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} offset={{ closed: 0, opened: 0 }} > - + {props.activeWorkStartedAt ? ( ) : null} @@ -358,11 +427,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} ; - readonly httpBaseUrl: string | null; - readonly bearerToken: string | null; + readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; readonly latestTurn: ThreadFeedLatestTurn | null; + readonly listRef: RefObject; + readonly freeze: SharedValue; + readonly anchorMessageId: MessageId | null; + readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; - readonly layoutVariant?: MobileLayoutVariant; - readonly composerExpanded?: boolean; + readonly layoutVariant?: LayoutVariant; readonly skills?: ReadonlyArray; } -function stripShellWrapper(value: string): string { - const trimmed = value.trim(); - const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); - return (match?.[1] ?? trimmed).trim(); -} +function MessageAttachmentImage(props: { + readonly environmentId: EnvironmentId; + readonly attachmentId: string; + readonly className: string; + readonly onPressImage: (uri: string, headers?: Record) => void; +}) { + const uri = useAssetUrl(props.environmentId, { + _tag: "attachment", + attachmentId: props.attachmentId, + }); -function compactActivityDetail(detail: string | null): string | null { - if (!detail) { - return null; + if (uri === null) { + return ( + + + + ); } - const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); - return cleaned.length > 0 ? cleaned : null; -} - -function buildActivityRows( - activities: Extract["activities"], -) { - return activities.map((activity) => ({ - ...activity, - detail: compactActivityDetail(activity.detail), - })); + return ( + props.onPressImage(uri)}> + + + ); } -const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; - const MARKDOWN_COLORS = { light: { body: "#111111", @@ -128,10 +144,12 @@ const MARKDOWN_COLORS = { blockquoteBackground: "rgba(0, 0, 0, 0.02)", codeBackground: "rgba(0, 0, 0, 0.04)", codeText: "#262626", + inlineCodeText: "#5f6368", horizontalRule: "rgba(0, 0, 0, 0.08)", userBody: "#ffffff", userCodeBackground: "rgba(255, 255, 255, 0.22)", userCodeText: "#ffffff", + userInlineCodeText: "rgba(255, 255, 255, 0.82)", userFenceBackground: "rgba(0, 0, 0, 0.16)", userFenceText: "#ffffff", }, @@ -143,10 +161,12 @@ const MARKDOWN_COLORS = { blockquoteBackground: "rgba(255, 255, 255, 0.03)", codeBackground: "rgba(255, 255, 255, 0.06)", codeText: "#e5e5e5", + inlineCodeText: "#b8bcc2", horizontalRule: "rgba(255, 255, 255, 0.08)", userBody: "#ffffff", userCodeBackground: "rgba(255, 255, 255, 0.18)", userCodeText: "#ffffff", + userInlineCodeText: "rgba(255, 255, 255, 0.82)", userFenceBackground: "rgba(0, 0, 0, 0.28)", userFenceText: "#ffffff", }, @@ -175,27 +195,18 @@ interface ReviewCommentColors { const failedMarkdownFaviconHosts = new Set(); const markdownLinkStyles = StyleSheet.create({ - favicon: { + inlineIcon: { width: 14, height: 14, - borderRadius: 3, marginHorizontal: 3, transform: [{ translateY: 2 }], }, - file: { - borderRadius: 5, - borderWidth: StyleSheet.hairlineWidth, - fontFamily: "DMSans_500Medium", - fontSize: 13, - lineHeight: 20, - paddingHorizontal: 6, - paddingVertical: 2, + favicon: { + borderRadius: 3, }, - fileIcon: { - width: 15, - height: 15, - marginRight: 4, - transform: [{ translateY: 2 }], + file: { + fontFamily: "DMSans_700Bold", + fontWeight: "700", }, }); @@ -223,7 +234,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { source={{ uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`, }} - style={markdownLinkStyles.favicon} + style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]} onError={() => { failedMarkdownFaviconHosts.add(props.host); setFailed(true); @@ -260,11 +271,9 @@ function useReviewCommentColors(): ReviewCommentColors { ); } -function useMarkdownStyles(): MarkdownStyleSets { +function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { const colorScheme = useColorScheme(); const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; - const inlineChipBackground = String(useThemeColor("--color-subtle")); - const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background")); const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); return useMemo(() => { @@ -275,10 +284,12 @@ function useMarkdownStyles(): MarkdownStyleSets { const markdownBlockquoteBorder = colors.blockquoteBorder; const markdownCodeBg = colors.codeBackground; const markdownCodeText = colors.codeText; + const markdownInlineCodeText = colors.inlineCodeText; const markdownHrColor = colors.horizontalRule; const markdownUserBodyColor = colors.userBody; const markdownUserCodeBg = colors.userCodeBackground; const markdownUserCodeText = colors.userCodeText; + const markdownUserInlineCodeText = colors.userInlineCodeText; const markdownUserFenceBg = colors.userFenceBackground; const markdownUserFenceText = colors.userFenceText; @@ -367,28 +378,23 @@ function useMarkdownStyles(): MarkdownStyleSets { }; const createMarkdownRenderers = ( - inlineBackgroundColor: string, inlineTextColor: string, + inlineCodeTextColor: string, blockBackgroundColor: string, blockTextColor: string, + preserveSoftBreaks: boolean, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { return ( onLinkPress(href)} + style={[markdownLinkStyles.file, { color: inlineTextColor }]} > {presentation.label} @@ -448,8 +454,7 @@ function useMarkdownStyles(): MarkdownStyleSets { marginRight: 5, color: inlineTextColor, fontFamily: "DMSans_400Regular", - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, textAlign: ordered ? "right" : "center", }} > @@ -465,28 +470,24 @@ function useMarkdownStyles(): MarkdownStyleSets { ), code_inline: ({ content }) => { const value = content ?? ""; - const wrapsPoorly = - value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":"); return ( {value} ); }, + ...(preserveSoftBreaks + ? { + soft_break: () => {"\n"}, + } + : {}), code_block: ({ content, language }) => ( @@ -592,27 +593,26 @@ function useMarkdownStyles(): MarkdownStyleSets { theme: userTheme, styles: userStyles, renderers: createMarkdownRenderers( - markdownUserCodeBg, markdownUserCodeText, + markdownUserInlineCodeText, markdownUserFenceBg, markdownUserFenceText, + true, ), nativeTextStyle: { color: markdownUserBodyColor, strongColor: markdownUserBodyColor, mutedColor: markdownUserBodyColor, linkColor: markdownUserBodyColor, + inlineCodeColor: markdownUserInlineCodeText, codeColor: markdownUserCodeText, codeBackgroundColor: markdownUserCodeBg, codeBlockBackgroundColor: markdownUserFenceBg, - fileBackgroundColor: "rgba(255, 255, 255, 0.12)", fileTextColor: "#ffffff", - skillBackgroundColor: "rgba(217, 70, 239, 0.24)", - skillTextColor: "#ffffff", + skillTextColor: "#f0abfc", quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", @@ -622,39 +622,38 @@ function useMarkdownStyles(): MarkdownStyleSets { theme: assistantTheme, styles: assistantStyles, renderers: createMarkdownRenderers( - markdownCodeBg, markdownCodeText, + markdownInlineCodeText, markdownCodeBg, markdownCodeText, + false, ), nativeTextStyle: { color: markdownBodyColor, strongColor: markdownStrongColor, mutedColor: markdownBodyColor, linkColor: markdownLinkColor, + inlineCodeColor: markdownInlineCodeText, codeColor: markdownCodeText, codeBackgroundColor: markdownCodeBg, codeBlockBackgroundColor: markdownCodeBg, - fileBackgroundColor: inlineChipBackground, fileTextColor: markdownCodeText, - skillBackgroundColor: inlineSkillBackground, skillTextColor: inlineSkillForeground, quoteMarkerColor: markdownBlockquoteBorder, dividerColor: markdownHrColor, - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", }, }, }; - }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]); + }, [colors, inlineSkillForeground, onLinkPress]); } function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; readonly expandedWorkRows: Record; @@ -665,11 +664,13 @@ function renderFeedEntry( readonly onToggleWorkRow: (rowId: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onMarkdownLinkPress: (href: string) => void; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; readonly reviewCommentColors: ReviewCommentColors; readonly reviewCommentBubbleWidth: number; + readonly userBubbleMaxWidth: number; }, ) { const entry = info.item; @@ -718,9 +719,10 @@ function renderFeedEntry( return ( @@ -730,29 +732,18 @@ function renderFeedEntry( markdownStyles={styles} reviewCommentColors={props.reviewCommentColors} skills={props.skills} + onLinkPress={props.onMarkdownLinkPress} /> ) : null} {attachments.map((attachment) => { - const uri = messageImageUrl(props.httpBaseUrl, attachment.id); - if (!uri) { - return null; - } - const headers = props.bearerToken - ? { Authorization: `Bearer ${props.bearerToken}` } - : undefined; - return ( - props.onPressImage(uri, headers)} - > - - + environmentId={props.environmentId} + attachmentId={attachment.id} + className="aspect-[1.3] w-full rounded-[14px] bg-white/15" + onPressImage={props.onPressImage} + /> ); })} @@ -788,6 +779,7 @@ function renderFeedEntry( markdown={message.text} skills={props.skills} textStyle={styles.nativeTextStyle} + onLinkPress={props.onMarkdownLinkPress} /> ) : ( { - const uri = messageImageUrl(props.httpBaseUrl, attachment.id); - if (!uri) { - return null; - } - const headers = props.bearerToken - ? { Authorization: `Bearer ${props.bearerToken}` } - : undefined; - return ( - props.onPressImage(uri, headers)} - > - - + environmentId={props.environmentId} + attachmentId={attachment.id} + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" + onPressImage={props.onPressImage} + /> ); })} {showAssistantMeta ? ( @@ -842,147 +821,17 @@ function renderFeedEntry( ); } - if (entry.type === "queued-message") { - return ( - - - - {entry.queuedMessage.text} - - {entry.queuedMessage.attachments.length > 0 ? ( - - {entry.queuedMessage.attachments.length} image - {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached - - ) : null} - - - {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} - - - ); - } - - const rows = buildActivityRows(entry.activities).filter( - (activity) => !(activity.toolLike && activity.status === "neutral"), - ); - if (rows.length === 0) { - return null; - } - const isExpanded = props.expandedWorkGroups[entry.id] ?? false; - const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; - const hiddenCount = rows.length - visibleRows.length; - const onlyToolRows = rows.every((row) => row.toolLike); - const headerTitle = onlyToolRows - ? rows.length === 1 - ? "1 tool call" - : `${rows.length} tool calls` - : "Work log"; - return ( - - - {headerTitle} - {hasOverflow ? ( - props.onToggleWorkGroup(entry.id)} - className="flex-row items-center gap-1" - > - - {isExpanded ? "Show less" : `Show ${hiddenCount} more`} - - - - ) : null} - - {visibleRows.map((row, index) => ( - { - if (row.fullDetail) { - props.onToggleWorkRow(row.id); - } - }} - onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)} - className={cn( - "rounded-lg px-2 py-1.5", - index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", - )} - > - - - - - - {row.detail ? `${row.summary} - ${row.detail}` : row.summary} - - {row.fullDetail ? ( - - ) : null} - {props.copiedRowId === row.id ? ( - - Copied - - ) : null} - - {row.fullDetail && props.expandedWorkRows[row.id] ? ( - - - {row.fullDetail} - - - ) : null} - - ))} - + props.onToggleWorkGroup(entry.id)} + onToggleRow={props.onToggleWorkRow} + /> ); } @@ -991,6 +840,7 @@ function UserMessageContent(props: { readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; readonly skills?: ReadonlyArray; + readonly onLinkPress: (href: string) => void; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); @@ -1001,6 +851,8 @@ function UserMessageContent(props: { markdown={props.text} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} + preserveSoftBreaks + onLinkPress={props.onLinkPress} /> ); } @@ -1040,6 +892,8 @@ function UserMessageContent(props: { markdown={text} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} + preserveSoftBreaks + onLinkPress={props.onLinkPress} /> ) : ( @@ -1169,8 +1023,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { style={{ color: props.colors.text, fontFamily: "ui-monospace", - fontSize: 12, - lineHeight: 18, + fontSize: MOBILE_CODE_SURFACE.fontSize, + lineHeight: MOBILE_CODE_SURFACE.rowHeight, }} > {props.comment.diff.trim()} @@ -1181,7 +1035,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { {props.comment.text} @@ -1220,18 +1074,45 @@ function compactFileName(filePath: string): string { return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; } +function ThreadFeedPlaceholder(props: { + readonly bottomInset: number; + readonly detail: string; + readonly horizontalPadding: number; + readonly loading?: boolean; + readonly title: string; + readonly topInset: number; +}) { + return ( + + + {props.loading ? : null} + {props.title} + + {props.detail} + + + + ); +} + export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { - const listRef = useRef(null); + const router = useRouter(); const copyFeedbackTimeoutRef = useRef | null>(null); - const scrollFrameRef = useRef(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); - const suppressAutoFollowRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); - const isNearEndRef = useRef(true); - const initialScrollReadyRef = useRef(false); - const lastContentHeightRef = useRef(0); const { width: viewportWidth } = useWindowDimensions(); + const [foldToggleSettling, setFoldToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1250,6 +1131,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } | null>(null); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); + const userBubbleMaxWidth = contentWidth * 0.85; const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = props.contentTopInset ?? insets.top + 44; @@ -1257,21 +1139,71 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); - const markdownStyles = useMarkdownStyles(); + const onMarkdownLinkPress = useCallback( + (href: string) => { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind === "file") { + const relativePath = resolveWorkspaceRelativeFilePath( + props.workspaceRoot, + presentation.path, + ); + if (relativePath) { + void Haptics.selectionAsync(); + router.push( + buildThreadFilesNavigation( + { environmentId: props.environmentId, threadId: props.threadId }, + relativePath, + presentation.line, + ), + ); + } + return; + } + + if (presentation.href) { + void Linking.openURL(presentation.href); + } + }, + [props.environmentId, props.threadId, props.workspaceRoot, router], + ); + const markdownStyles = useMarkdownStyles(onMarkdownLinkPress); const reviewCommentColors = useReviewCommentColors(); + // LegendList does not invalidate visible rows when only the renderItem closure changes. + // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. const listAppearanceData = useMemo( () => ({ + copiedRowId, + expandedWorkGroups, + expandedWorkRows, iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor, }), - [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor], + [ + copiedRowId, + expandedWorkGroups, + expandedWorkRows, + iconSubtleColor, + markdownStyles, + reviewCommentColors, + userBubbleColor, + ], ); const presentedFeed = useMemo( () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), [expandedTurnIds, props.feed, props.latestTurn], ); + const anchoredEndSpace = useMemo( + () => + resolveChatListAnchoredEndSpace( + presentedFeed, + props.anchorMessageId, + (entry) => (entry.type === "message" ? entry.id : null), + { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET }, + ), + [presentedFeed, props.anchorMessageId, topContentInset], + ); const terminalAssistantMessageIds = useMemo(() => { const terminalIdsByTurn = new Map(); for (const entry of props.feed) { @@ -1287,54 +1219,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? props.latestTurn.turnId : null; - const scrollToEnd = useCallback(() => { - if (scrollFrameRef.current !== null) { - return; - } - scrollFrameRef.current = requestAnimationFrame(() => { - scrollFrameRef.current = null; - listRef.current?.scrollToEnd({ animated: false }); - }); - }, []); - - const onListScroll = useCallback( - (event: NativeSyntheticEvent | NativeScrollEvent) => { - const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; - const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; - isNearEndRef.current = isThreadFeedNearEnd( - { - contentHeight: contentSize.height, - viewportHeight: layoutMeasurement.height, - offsetY: contentOffset.y, - bottomInset: contentInset.bottom, - }, - THREAD_FEED_END_THRESHOLD, - ); - }, - [], - ); - - const onListContentSizeChange = useCallback( - (_width: number, height: number) => { - const contentGrew = height > lastContentHeightRef.current + 0.5; - lastContentHeightRef.current = height; - - if ( - initialScrollReadyRef.current && - contentGrew && - isNearEndRef.current && - !suppressAutoFollowRef.current - ) { - scrollToEnd(); - } - }, - [scrollToEnd], - ); - - const onListLoad = useCallback(() => { - initialScrollReadyRef.current = true; - }, []); - useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; @@ -1366,9 +1250,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } - if (scrollFrameRef.current !== null) { - cancelAnimationFrame(scrollFrameRef.current); - } if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1379,8 +1260,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { - void Clipboard.setStringAsync(value); - void Haptics.selectionAsync(); + copyTextWithHaptic(value, { + target: "thread-work-row", + feedback: "selection", + }); setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); @@ -1414,7 +1297,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onToggleTurnFold = useCallback((turnId: TurnId) => { - suppressAutoFollowRef.current = true; + setFoldToggleSettling(true); if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1432,7 +1315,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); foldSettleFrameRef.current = requestAnimationFrame(() => { foldSettleSecondFrameRef.current = requestAnimationFrame(() => { - suppressAutoFollowRef.current = false; + setFoldToggleSettling(false); foldSettleFrameRef.current = null; foldSettleSecondFrameRef.current = null; }); @@ -1446,9 +1329,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => renderFeedEntry(info, { - bearerToken: props.bearerToken, + environmentId: props.environmentId, copiedRowId, - httpBaseUrl: props.httpBaseUrl, expandedWorkGroups, expandedWorkRows, terminalAssistantMessageIds, @@ -1458,11 +1340,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, onToggleTurnFold, onPressImage, + onMarkdownLinkPress, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + userBubbleMaxWidth, skills: props.skills, }), [ @@ -1476,79 +1360,97 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + userBubbleMaxWidth, onCopyWorkRow, + onMarkdownLinkPress, onPressImage, onToggleTurnFold, onToggleWorkGroup, onToggleWorkRow, - props.bearerToken, - props.httpBaseUrl, + props.environmentId, props.skills, ], ); - if (props.feed.length === 0) { + if (props.contentPresentation.kind === "loading") { return ( - - - + + ); + } + + if (props.contentPresentation.kind === "unavailable") { + return ( + ); } return ( <> - `${entry.type}:${entry.id}`} + keyExtractor={(entry) => entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } keyboardShouldPersistTaps="always" keyboardDismissMode="none" + keyboardLiftBehavior="whenAtEnd" estimatedItemSize={180} initialScrollAtEnd - onContentSizeChange={onListContentSizeChange} - onLoad={onListLoad} - onScroll={onListScroll} - scrollEventThrottle={16} ListHeaderComponent={} contentContainerStyle={{ paddingTop: 12, - paddingBottom: bottomContentInset, paddingHorizontal: horizontalPadding, }} /> + {props.feed.length === 0 ? ( + + + + ) : null} ; readonly terminalSessions: ReadonlyArray; readonly onOpenTerminal: (terminalId?: string | null) => void; @@ -124,13 +126,8 @@ export function ThreadGitControls(props: { Alert.alert("No open PR", "This branch does not have an open pull request."); return; } - try { - await Linking.openURL(prUrl); - } catch (error) { - Alert.alert( - "Unable to open PR", - error instanceof Error ? error.message : "An error occurred.", - ); + if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { + Alert.alert("Unable to open PR", "The pull request could not be opened."); } }, [gitStatus]); @@ -259,6 +256,14 @@ export function ThreadGitControls(props: { > Review changes + router.push(buildThreadFilesNavigation({ environmentId, threadId }))} + subtitle="Browse this workspace" + > + Files + diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx index 84ae71dce5c..9318fb76017 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx @@ -1,6 +1,13 @@ import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import { + type ColorValue, + Modal, + Pressable, + ScrollView, + useWindowDimensions, + View, +} from "react-native"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -15,21 +22,19 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { StatusPill } from "../../components/StatusPill"; +import { useProjects, useThreadShells } from "../../state/entities"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { relativeTime } from "../../lib/time"; import { threadStatusTone } from "./threadPresentation"; -import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, -} from "@t3tools/client-runtime"; +import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; const threadActivityOrder = Order.mapInput( Order.Struct({ activityAt: Order.flip(Order.Number), title: Order.String, }), - (thread: EnvironmentScopedThreadShell) => ({ + (thread: EnvironmentThreadShell) => ({ activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), title: thread.title, }), @@ -37,11 +42,9 @@ const threadActivityOrder = Order.mapInput( export function ThreadNavigationDrawer(props: { readonly visible: boolean; - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; readonly selectedThreadKey: string | null; readonly onClose: () => void; - readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onStartNewTask: () => void; }) { const insets = useSafeAreaInsets(); @@ -57,26 +60,6 @@ export function ThreadNavigationDrawer(props: { const primaryForeground = useThemeColor("--color-primary-foreground"); const borderSubtleColor = useThemeColor("--color-border-subtle"); - const repositoryGroups = useMemo( - () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), - [props.projects, props.threads], - ); - const groupedThreads = useMemo( - () => - repositoryGroups.map((group) => { - const threads: EnvironmentScopedThreadShell[] = []; - for (const projectGroup of group.projects) { - threads.push(...projectGroup.threads); - } - return { - key: group.key, - title: group.projects[0]?.project.title ?? group.title, - threads: Arr.sort(threads, threadActivityOrder), - }; - }), - [repositoryGroups], - ); - useEffect(() => { if (props.visible) { setMounted(true); @@ -169,7 +152,7 @@ export function ThreadNavigationDrawer(props: { ]} > - Threads + Threads { props.onClose(); @@ -186,76 +169,114 @@ export function ThreadNavigationDrawer(props: { - - {groupedThreads.map((group) => ( - - - {group.title} - - - - {group.threads.length === 0 ? ( - - - No threads yet - - - ) : ( - group.threads.map((thread, index) => { - const threadKey = scopedThreadKey(thread.environmentId, thread.id); - const selected = props.selectedThreadKey === threadKey; - - return ( - { - props.onSelectThread(thread); - props.onClose(); - }} - style={{ - paddingHorizontal: 16, - paddingVertical: 15, - borderTopWidth: index === 0 ? 0 : 1, - borderTopColor: borderSubtleColor, - backgroundColor: selected ? undefined : "transparent", - }} - className={selected ? "bg-subtle" : undefined} - > - - - - {thread.title} - - - {relativeTime(thread.updatedAt ?? thread.createdAt)} - - - - - - ); - }) - )} - - - ))} - + ); } + +function ThreadNavigationDrawerContent(props: { + readonly bottomInset: number; + readonly borderSubtleColor: ColorValue; + readonly selectedThreadKey: string | null; + readonly onClose: () => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; +}) { + const projects = useProjects(); + const threads = useThreadShells(); + const repositoryGroups = useMemo( + () => groupProjectsByRepository({ projects, threads }), + [projects, threads], + ); + const groupedThreads = useMemo( + () => + repositoryGroups.map((group) => { + const threads: EnvironmentThreadShell[] = []; + for (const projectGroup of group.projects) { + threads.push(...projectGroup.threads); + } + return { + key: group.key, + title: group.projects[0]?.project.title ?? group.title, + threads: Arr.sort(threads, threadActivityOrder), + }; + }), + [repositoryGroups], + ); + + return ( + + {groupedThreads.map((group) => ( + + + {group.title} + + + + {group.threads.length === 0 ? ( + + No threads yet + + ) : ( + group.threads.map((thread, index) => { + const threadKey = scopedThreadKey(thread.environmentId, thread.id); + const selected = props.selectedThreadKey === threadKey; + + return ( + { + props.onSelectThread(thread); + props.onClose(); + }} + style={{ + paddingHorizontal: 16, + paddingVertical: 15, + borderTopWidth: index === 0 ? 0 : 1, + borderTopColor: props.borderSubtleColor, + backgroundColor: selected ? undefined : "transparent", + }} + className={selected ? "bg-subtle" : undefined} + > + + + + {thread.title} + + + {relativeTime(thread.updatedAt ?? thread.createdAt)} + + + + + + ); + }) + )} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 84c4b343a93..f8c916974e5 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,28 +1,29 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useState } from "react"; -import * as Arr from "effect/Array"; import * as Option from "effect/Option"; -import { pipe } from "effect/Function"; import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Pressable, ScrollView, Text as RNText, View } from "react-native"; +import { useWorkspaceState } from "../../state/workspace"; import { useThemeColor } from "../../lib/useThemeColor"; -import { useVcsStatus } from "../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; +import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { connectionTone } from "../connection/connectionTone"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; import { + useRemoteConnections, useRemoteConnectionStatus, - useRemoteEnvironmentState, + useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; -import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { @@ -38,12 +39,14 @@ import { terminalDebugLog } from "../terminal/terminalDebugLog"; import { ThreadDetailScreen } from "./ThreadDetailScreen"; import { ThreadGitControls } from "./ThreadGitControls"; import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; -import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; +import { threadEnvironment } from "../../state/threads"; +import { projectThreadContentPresentation } from "./threadContentPresentation"; function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -58,22 +61,19 @@ function OpeningThreadLoadingScreen() { } export function ThreadRouteScreen() { - const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } = - useRemoteEnvironmentState(); - const { connectionState, connectionError: aggregateConnectionError } = - useRemoteConnectionStatus(); - const { projects, threads } = useRemoteCatalog(); + const { state: workspaceState } = useWorkspaceState(); + const { connectionState } = useRemoteConnectionStatus(); + const { onReconnectEnvironment } = useRemoteConnections(); const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); - const selectedThreadDetail = useSelectedThreadDetail(); + const selectedThreadDetailState = useSelectedThreadDetailState(); + const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); - const commands = useSelectedThreadCommands({ - refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus, - }); + const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const router = useRouter(); const params = useLocalSearchParams<{ environmentId?: string | string[]; @@ -83,12 +83,22 @@ export function ThreadRouteScreen() { const environmentIdRaw = firstRouteParam(params.environmentId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const threadId = firstRouteParam(params.threadId); - const routeEnvironmentRuntime = environmentId - ? (environmentStateById[environmentId] ?? null) - : null; - const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState; - const routeConnectionError = - pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; + const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); + const routeConnectionState = + routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); + const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; + const selectedThreadWithDraftSettings = useMemo( + () => + selectedThread + ? { + ...selectedThread, + modelSelection: composer.modelSelection ?? selectedThread.modelSelection, + runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode, + interactionMode: composer.interactionMode ?? selectedThread.interactionMode, + } + : null, + [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread], + ); /* ─── Native header theming ──────────────────────────────────────── */ const iconColor = String(useThemeColor("--color-icon")); @@ -96,10 +106,14 @@ export function ThreadRouteScreen() { const secondaryFg = String(useThemeColor("--color-foreground-secondary")); /* ─── Git status for native header trigger ───────────────────────── */ - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, @@ -113,6 +127,12 @@ export function ThreadRouteScreen() { [knownTerminalSessions, selectedThreadProject?.workspaceRoot], ); const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; + const handleReconnectEnvironment = useCallback(() => { + if (!environmentId) { + return; + } + onReconnectEnvironment(environmentId); + }, [environmentId, onReconnectEnvironment]); /* ─── Git action progress (for overlay banner) ──────────────────── */ const gitActionProgressTarget = useMemo( @@ -131,6 +151,24 @@ export function ThreadRouteScreen() { const handleOpenConnectionEditor = useCallback(() => { void router.push("/connections"); }, [router]); + const handleStopThread = useCallback(() => { + if ( + !selectedThread || + (selectedThread.session?.status !== "running" && + selectedThread.session?.status !== "starting") + ) { + return; + } + return interruptThreadTurn({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + ...(selectedThread.session.activeTurnId + ? { turnId: selectedThread.session.activeTurnId } + : {}), + }, + }); + }, [interruptThreadTurn, selectedThread]); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { @@ -238,7 +276,7 @@ export function ThreadRouteScreen() { if (!selectedThread) { const stillHydrating = - isLoadingSavedConnection || + workspaceState.isLoadingConnections || routeConnectionState === "connecting" || routeConnectionState === "reconnecting"; @@ -265,19 +303,14 @@ export function ThreadRouteScreen() { ); } - if (!selectedThreadDetail) { - return ; - } - const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); - const serverConfig = - routeEnvironmentRuntime?.serverConfig ?? - pipe( - Object.values(environmentStateById), - Arr.map((runtime) => runtime.serverConfig), - Arr.findFirst((value) => value !== null), - Option.getOrNull, - ); + const contentPresentation = projectThreadContentPresentation({ + hasDetail: selectedThreadDetail !== null, + detailError: Option.getOrNull(selectedThreadDetailState.error), + detailDeleted: selectedThreadDetailState.status === "deleted", + connectionState: routeConnectionState, + }); + const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const headerSubtitle = [ selectedThreadProject?.title ?? null, @@ -307,19 +340,19 @@ export function ThreadRouteScreen() { numberOfLines={1} style={{ fontFamily: "DMSans_700Bold", - fontSize: 18, + fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, fontWeight: "900", color: foregroundColor, letterSpacing: -0.4, }} > - {selectedThreadDetail.title} + {selectedThread.title} setDrawerVisible(false)} onSelectThread={(thread) => { diff --git a/apps/mobile/src/features/threads/claudeEffortOptions.ts b/apps/mobile/src/features/threads/claudeEffortOptions.ts deleted file mode 100644 index 58a4032b0ba..00000000000 --- a/apps/mobile/src/features/threads/claudeEffortOptions.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const CLAUDE_AGENT_EFFORT_OPTIONS = [ - "low", - "medium", - "high", - "xhigh", - "max", - "ultrathink", -] as const; - -export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number]; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index a6b29fbe431..e27136702f2 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -6,11 +6,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { SheetActionButton } from "./gitSheetComponents"; export function GitBranchesSheet() { @@ -27,10 +28,14 @@ export function GitBranchesSheet() { const foregroundColor = useThemeColor("--color-foreground"); const subtleStrongColor = useThemeColor("--color-subtle-strong"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; const currentWorktreePath = selectedThreadWorktreePath; @@ -64,7 +69,7 @@ export function GitBranchesSheet() { > New branch @@ -73,7 +78,7 @@ export function GitBranchesSheet() { value={newBranchName} onChangeText={setNewBranchName} placeholder="feature/mobile-polish" - className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" + className="rounded-[18px] px-3.5 py-3 font-sans text-base" style={{ borderWidth: 1, borderColor: inputBorderColor, @@ -99,7 +104,7 @@ export function GitBranchesSheet() { New worktree @@ -108,7 +113,7 @@ export function GitBranchesSheet() { value={worktreeBaseBranch} onChangeText={setWorktreeBaseBranch} placeholder="main" - className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" + className="rounded-[18px] px-3.5 py-3 font-sans text-base" style={{ borderWidth: 1, borderColor: inputBorderColor, @@ -120,7 +125,7 @@ export function GitBranchesSheet() { value={worktreeBranchName} onChangeText={setWorktreeBranchName} placeholder="feature/mobile-thread" - className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" + className="rounded-[18px] px-3.5 py-3 font-sans text-base" style={{ borderWidth: 1, borderColor: inputBorderColor, @@ -149,18 +154,16 @@ export function GitBranchesSheet() { Existing branches {branchesLoading ? ( - - Loading branches... - + Loading branches... ) : null} {!branchesLoading && availableBranches.length === 0 ? ( - + No local branches found. ) : null} @@ -190,8 +193,8 @@ export function GitBranchesSheet() { }} > - {branch.name} - {subtitle} + {branch.name} + {subtitle} ); })} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 478e2642035..76e0daf5f0a 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -5,11 +5,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { SheetActionButton } from "./gitSheetComponents"; export function GitCommitSheet() { @@ -27,10 +28,14 @@ export function GitCommitSheet() { const inputBg = useThemeColor("--color-input"); const foregroundColor = useThemeColor("--color-foreground"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const busy = gitState.gitOperationLabel !== null; const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; @@ -74,14 +79,14 @@ export function GitCommitSheet() { > - Branch - + Branch + {gitStatus.data?.refName ?? "(detached HEAD)"} {isDefaultRef ? ( Warning: this is the default branch. @@ -92,8 +97,8 @@ export function GitCommitSheet() { - Files - + Files + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} @@ -103,14 +108,14 @@ export function GitCommitSheet() { className="bg-subtle rounded-full px-3 py-2" onPress={() => setExcludedFiles(new Set())} > - Reset + Reset ) : null} setIsEditingFiles((current) => !current)} > - + {isEditingFiles ? "Done" : "Edit"} @@ -118,26 +123,26 @@ export function GitCommitSheet() { {allFiles.length === 0 ? ( - + No changed files are available to commit. ) : !isEditingFiles ? ( {selectedFilePreview.map((file) => ( - + {file.path} - + +{file.insertions} - + -{file.deletions} ))} {selectedFiles.length > selectedFilePreview.length ? ( - + +{selectedFiles.length - selectedFilePreview.length} more files ) : null} @@ -172,21 +177,21 @@ export function GitCommitSheet() { {file.path} {!included ? ( - + Excluded from this commit ) : null} - + +{file.insertions} - + -{file.deletions} @@ -199,14 +204,14 @@ export function GitCommitSheet() { - Commit message + Commit message Confirm - + {copy?.title ?? "Run action on default branch?"} - + {copy?.description ?? "Choose how to continue."} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index a940fcdfcc3..0db7876a774 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -3,22 +3,24 @@ import { buildMenuItems, getGitActionDisabledReason, requiresDefaultBranchConfirmation, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useMemo } from "react"; -import { Alert, Linking, Pressable, ScrollView, View } from "react-native"; +import { Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; +import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; import { buildThreadReviewRoutePath } from "../../../lib/routes"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; export function GitOverviewSheet() { @@ -36,10 +38,14 @@ export function GitOverviewSheet() { const iconColor = useThemeColor("--color-icon"); const borderColor = useThemeColor("--color-border"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; const currentWorktreePath = selectedThreadWorktreePath; @@ -78,13 +84,8 @@ export function GitOverviewSheet() { Alert.alert("No open PR", "This branch does not have an open pull request."); return; } - try { - await Linking.openURL(prUrl); - } catch (error) { - Alert.alert( - "Unable to open PR", - error instanceof Error ? error.message : "An error occurred.", - ); + if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { + Alert.alert("Unable to open PR", "The pull request could not be opened."); } }, [gitStatus.data]); @@ -170,13 +171,13 @@ export function GitOverviewSheet() { /> Branch - {currentBranchLabel} - + {currentBranchLabel} + {statusSummary(gitStatus.data)} diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index b13f6a3020c..16c311bff57 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -56,7 +56,7 @@ export function SheetActionButton(props: { > {props.label} @@ -69,12 +69,12 @@ export function MetaCard(props: { readonly label: string; readonly value: string return ( {props.label} - + {props.value} @@ -102,9 +102,9 @@ export function SheetListRow(props: { - {props.title} + {props.title} {props.subtitle ? ( - {props.subtitle} + {props.subtitle} ) : null} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index fb93d379a7f..9a8dde3429a 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,9 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import type { EnvironmentId, ModelSelection, ProviderInteractionMode, + ProviderOptionSelection, RuntimeMode, ServerProviderSkill, } from "@t3tools/contracts"; @@ -11,6 +12,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tool import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -21,23 +23,20 @@ import { removeComposerDraftAttachment, replaceComposerDraftAttachments, setComposerDraftText, + updateComposerDraftSettings, useComposerDraft, } from "../../state/use-composer-drafts"; -import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; +import { useBranches } from "../../state/queries"; import { setPendingConnectionError, - useRemoteEnvironmentState, + useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; -import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; -import type { ClaudeAgentEffort } from "./claudeEffortOptions"; +import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; type WorkspaceMode = "local" | "worktree"; -function normalizeSelectedWorktreePath( - project: EnvironmentScopedProjectShell, - branch: VcsRef, -): string | null { +function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { if (!branch.worktreePath) { return null; } @@ -47,7 +46,7 @@ function normalizeSelectedWorktreePath( export function branchBadgeLabel(input: { readonly branch: VcsRef; - readonly project: EnvironmentScopedProjectShell | null; + readonly project: EnvironmentProject | null; }): string | null { if (input.branch.current) { return "current"; @@ -67,7 +66,7 @@ export function branchBadgeLabel(input: { type NewTaskFlowContextValue = { readonly logicalProjects: ReadonlyArray<{ readonly key: string; - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; }>; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; @@ -83,15 +82,12 @@ type NewTaskFlowContextValue = { readonly availableBranches: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; - readonly effort: ClaudeAgentEffort; - readonly fastMode: boolean; - readonly contextWindow: string; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; }>; - readonly selectedProject: EnvironmentScopedProjectShell | null; + readonly selectedProject: EnvironmentProject | null; readonly modelOptions: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; @@ -99,7 +95,7 @@ type NewTaskFlowContextValue = { readonly providerGroups: ReadonlyArray; readonly filteredBranches: ReadonlyArray; readonly reset: () => void; - readonly setProject: (project: EnvironmentScopedProjectShell) => void; + readonly setProject: (project: EnvironmentProject) => void; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly setSelectedModelKey: (key: string | null) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; @@ -114,17 +110,18 @@ type NewTaskFlowContextValue = { readonly loadBranches: () => Promise; readonly setRuntimeMode: (value: RuntimeMode) => void; readonly setInteractionMode: (value: ProviderInteractionMode) => void; - readonly setEffort: (value: ClaudeAgentEffort) => void; - readonly setFastMode: (value: boolean) => void; - readonly setContextWindow: (value: string) => void; + readonly setSelectedModelOptions: ( + value: ReadonlyArray | undefined, + ) => void; readonly setExpandedProvider: (value: string | null) => void; }; const NewTaskFlowContext = React.createContext(null); export function NewTaskFlowProvider(props: React.PropsWithChildren) { - const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const projects = useProjects(); + const threads = useThreadShells(); + const { savedConnectionsById } = useSavedRemoteConnections(); const repositoryGroups = useMemo( () => groupProjectsByRepository({ projects, threads }), @@ -146,64 +143,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { entry, ): entry is { readonly key: string; - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; } => entry !== null, ), ), [repositoryGroups], ); - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( - projects[0]?.environmentId ?? null, + const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState( + null, ); + const selectedEnvironmentId = + selectedEnvironmentIdOverride !== null && + projects.some((project) => project.environmentId === selectedEnvironmentIdOverride) + ? selectedEnvironmentIdOverride + : (projects[0]?.environmentId ?? null); const [selectedProjectKey, setSelectedProjectKey] = useState(null); - const [selectedModelKey, setSelectedModelKey] = useState(null); - const [workspaceMode, setWorkspaceMode] = useState("local"); - const [selectedBranchName, setSelectedBranchName] = useState(null); - const [selectedWorktreePath, setSelectedWorktreePath] = useState(null); - const branchLoadVersionRef = useRef(0); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const [runtimeMode, setRuntimeMode] = useState(DEFAULT_RUNTIME_MODE); - const [interactionMode, setInteractionMode] = useState( - DEFAULT_PROVIDER_INTERACTION_MODE, - ); - const [effort, setEffort] = useState("high"); - const [fastMode, setFastMode] = useState(false); - const [contextWindow, setContextWindow] = useState("1M"); const [expandedProvider, setExpandedProvider] = useState(null); const reset = useCallback(() => { - console.log("[new task flow] reset", { - defaultEnvironmentId: projects[0]?.environmentId ?? null, - projectCount: projects.length, - }); - setSelectedEnvironmentId(projects[0]?.environmentId ?? null); + setSelectedEnvironmentId(null); setSelectedProjectKey(null); - setSelectedModelKey(null); - setWorkspaceMode("local"); - setSelectedBranchName(null); - setSelectedWorktreePath(null); setSubmitting(false); setBranchQuery(""); - setRuntimeMode(DEFAULT_RUNTIME_MODE); - setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); - setEffort("high"); - setFastMode(false); - setContextWindow("1M"); setExpandedProvider(null); - }, [projects]); - - useEffect(() => { - if (selectedEnvironmentId !== null || projects.length === 0) { - return; - } - - console.log("[new task flow] initializing environment", { - environmentId: projects[0]!.environmentId, - }); - setSelectedEnvironmentId(projects[0]!.environmentId); - }, [projects, selectedEnvironmentId]); + }, []); const environments = useMemo( () => @@ -254,29 +220,42 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ) ?? projectsForEnvironment[0] ?? null; + const selectedEnvironmentServerConfig = useEnvironmentServerConfig( + selectedProject?.environmentId ?? null, + ); const selectedProjectDraftKey = selectedProject ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` : null; const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; + const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; + const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; + const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; const modelOptions = useMemo( () => buildModelOptions( - selectedProject - ? (serverConfigByEnvironmentId[selectedProject.environmentId] ?? null) - : null, - selectedProject?.defaultModelSelection ?? null, + selectedEnvironmentServerConfig, + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, ), - [selectedProject, serverConfigByEnvironmentId], + [ + selectedEnvironmentServerConfig, + selectedProject?.defaultModelSelection, + selectedProjectDraft.modelSelection, + ], ); const selectedModel = - modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? modelOptions[0]?.selection ?? null; + const selectedModelKey = selectedModel + ? `${selectedModel.instanceId}:${selectedModel.model}` + : null; const selectedModelOption = modelOptions.find( @@ -285,12 +264,45 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = - (selectedProject - ? serverConfigByEnvironmentId[selectedProject.environmentId] - : null - )?.providers.find((provider) => provider.instanceId === selectedModel?.instanceId)?.skills ?? - []; + const selectedProviderSkills = useMemo( + () => + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + )?.skills ?? [], + [selectedEnvironmentServerConfig, selectedModel?.instanceId], + ); + const setSelectedModelKey = useCallback( + (key: string | null) => { + if (!key || !selectedProjectDraftKey) { + return; + } + const option = modelOptions.find((candidate) => candidate.key === key); + if (!option) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: option.selection, + }); + }, + [modelOptions, selectedProjectDraftKey], + ); + const setSelectedModelOptions = useCallback( + (options: ReadonlyArray | undefined) => { + if (!selectedModel || !selectedProjectDraftKey) { + return; + } + const nextSelection: ModelSelection = options + ? { ...selectedModel, options } + : { + instanceId: selectedModel.instanceId, + model: selectedModel.model, + }; + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: nextSelection, + }); + }, + [selectedModel, selectedProjectDraftKey], + ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const setPrompt = useCallback( @@ -343,7 +355,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }), [selectedProject?.environmentId, selectedProject?.workspaceRoot], ); - const branchState = useVcsRefs(branchTarget); + const branchState = useBranches(branchTarget); const branchesLoading = branchState.isPending; const availableBranches = useMemo( () => @@ -366,71 +378,87 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - const setProject = useCallback((project: EnvironmentScopedProjectShell) => { + const setProject = useCallback((project: EnvironmentProject) => { const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(project.environmentId); setSelectedProjectKey(nextProjectKey); - setSelectedBranchName(null); - setSelectedWorktreePath(null); }, []); const selectEnvironment = useCallback((environmentId: EnvironmentId) => { - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(environmentId); setSelectedProjectKey(null); - setSelectedBranchName(null); - setSelectedWorktreePath(null); }, []); + const setWorkspaceMode = useCallback( + (mode: WorkspaceMode) => { + if (!selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode, + branch: selectedBranchName, + worktreePath: selectedWorktreePath, + }, + }); + }, + [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], + ); + const selectBranch = useCallback( (branch: VcsRef) => { - setSelectedBranchName(branch.name); - setSelectedWorktreePath( - selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null, - ); + if (!selectedProject || !selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: workspaceMode, + branch: branch.name, + worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), + }, + }); }, - [selectedProject], + [selectedProject, selectedProjectDraftKey, workspaceMode], ); + const refreshBranches = branchState.refresh; const loadBranches = useCallback(async () => { if (!selectedProject) { return; } + setPendingConnectionError(null); + refreshBranches(); + }, [refreshBranches, selectedProject]); - const loadVersion = ++branchLoadVersionRef.current; - const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); - try { - const result = await vcsRefManager.load({ - environmentId: selectedProject.environmentId, - cwd: selectedProject.workspaceRoot, - query: null, - }); - if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { - return; - } - setPendingConnectionError(null); - const branches = pipe( - result?.refs ?? [], - Arr.filter((branch) => !branch.isRemote), - ); - - if (workspaceMode === "worktree" && !selectedBranchName) { - const preferredBranch = - branches.find((branch) => branch.current)?.name ?? - branches.find((branch) => branch.isDefault)?.name ?? - null; - if (preferredBranch) { - setSelectedBranchName(preferredBranch); - } + useEffect(() => { + if (workspaceMode !== "worktree" || selectedBranchName !== null) { + return; + } + const preferredBranch = + availableBranches.find((branch) => branch.current) ?? + availableBranches.find((branch) => branch.isDefault) ?? + null; + if (preferredBranch) { + selectBranch(preferredBranch); + } + }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); + + const setRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); } - } catch { - if (loadVersion !== branchLoadVersionRef.current) { - return; + }, + [selectedProjectDraftKey], + ); + const setInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value }); } - setPendingConnectionError("Failed to load branches."); - } - }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]); + }, + [selectedProjectDraftKey], + ); const value = useMemo( () => ({ @@ -449,9 +477,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, runtimeMode, interactionMode, - effort, - fastMode, - contextWindow, expandedProvider, environments, selectedProject, @@ -477,9 +502,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { loadBranches, setRuntimeMode, setInteractionMode, - setEffort, - setFastMode, - setContextWindow, + setSelectedModelOptions, setExpandedProvider, }), [ @@ -487,11 +510,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, branchQuery, branchesLoading, - contextWindow, - effort, environments, expandedProvider, - fastMode, filteredBranches, interactionMode, loadBranches, @@ -508,12 +528,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModelKey, selectedModelOption, selectedProviderSkills, + setSelectedModelOptions, selectedProject, selectedProjectKey, selectedWorktreePath, setProject, selectBranch, selectEnvironment, + setInteractionMode, + setPrompt, + setRuntimeMode, + setSelectedModelKey, + setWorkspaceMode, submitting, workspaceMode, appendAttachments, @@ -522,24 +548,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ], ); - useEffect(() => { - console.log("[new task flow] state", { - availableBranchCount: availableBranches.length, - environmentCount: environments.length, - logicalProjectCount: logicalProjects.length, - selectedEnvironmentId, - selectedProjectKey, - selectedProjectTitle: selectedProject?.title ?? null, - }); - }, [ - availableBranches.length, - environments.length, - logicalProjects.length, - selectedEnvironmentId, - selectedProject?.title, - selectedProjectKey, - ]); - return {props.children}; } diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts new file mode 100644 index 00000000000..e4ad776e23d --- /dev/null +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts @@ -0,0 +1,56 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class ProjectThreadTaskRequiredError extends Schema.TaggedErrorClass()( + "ProjectThreadTaskRequiredError", + { + environmentId: EnvironmentId, + projectId: ProjectId, + environmentMode: Schema.Literals(["local", "worktree"]), + }, +) { + override get message(): string { + return "Enter a task before starting the thread."; + } +} + +export class ProjectThreadBaseBranchRequiredError extends Schema.TaggedErrorClass()( + "ProjectThreadBaseBranchRequiredError", + { + environmentId: EnvironmentId, + projectId: ProjectId, + }, +) { + override get message(): string { + return "Select a base branch before creating a worktree."; + } +} + +export const ProjectThreadCreationValidationError = Schema.Union([ + ProjectThreadTaskRequiredError, + ProjectThreadBaseBranchRequiredError, +]); +export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type; + +export function validateProjectThreadCreation(input: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly environmentMode: "local" | "worktree"; + readonly branch: string | null; + readonly initialMessageText: string; +}): ProjectThreadCreationValidationError | null { + if (input.initialMessageText.trim().length === 0) { + return new ProjectThreadTaskRequiredError({ + environmentId: input.environmentId, + projectId: input.projectId, + environmentMode: input.environmentMode, + }); + } + if (input.environmentMode === "worktree" && !input.branch) { + return new ProjectThreadBaseBranchRequiredError({ + environmentId: input.environmentId, + projectId: input.projectId, + }); + } + return null; +} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx new file mode 100644 index 00000000000..707e1a24f0d --- /dev/null +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -0,0 +1,261 @@ +import * as Haptics from "expo-haptics"; +import { SymbolView, type SFSymbol } from "expo-symbols"; +import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import type { ThreadFeedActivity } from "../../lib/threadActivity"; + +const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; +const WORK_LOG_LAYOUT_ANIMATION = { + duration: 180, + create: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, + update: { type: LayoutAnimation.Types.easeInEaseOut }, + delete: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, +} as const; + +function triggerDisclosureFeedback() { + LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION); + void Haptics.selectionAsync(); +} + +function stripShellWrapper(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); + return (match?.[1] ?? trimmed).trim(); +} + +function compactActivityDetail(detail: string | null): string | null { + if (!detail) { + return null; + } + + const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); + return cleaned.length > 0 ? cleaned : null; +} + +function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { + switch (icon) { + case "agent": + return "sparkles"; + case "alert": + return "exclamationmark.triangle"; + case "check": + return "checkmark"; + case "command": + return "terminal"; + case "edit": + return "square.and.pencil"; + case "eye": + return "eye"; + case "globe": + return "globe"; + case "hammer": + return "hammer"; + case "message": + return "bubble.left"; + case "warning": + return "xmark"; + case "wrench": + return "wrench"; + case "zap": + return "bolt"; + } +} + +export function ThreadWorkLog(props: { + readonly activities: ReadonlyArray; + readonly copiedRowId: string | null; + readonly expanded: boolean; + readonly expandedRows: Readonly>; + readonly iconSubtleColor: import("react-native").ColorValue; + readonly onCopyRow: (rowId: string, value: string) => void; + readonly onToggleGroup: () => void; + readonly onToggleRow: (rowId: string) => void; +}) { + const colorScheme = useColorScheme(); + const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; + const rows = props.activities + .filter((activity) => !(activity.toolLike && activity.status === "neutral")) + .map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) })); + + if (rows.length === 0) { + return null; + } + + const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleRows = + hasOverflow && !props.expanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; + const hiddenCount = rows.length - visibleRows.length; + const onlyToolRows = rows.every((row) => row.toolLike); + + return ( + + {!onlyToolRows ? ( + + work log + + ) : null} + + + {visibleRows.map((row) => { + const expanded = props.expandedRows[row.id] ?? false; + const canExpand = row.fullDetail !== null; + const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; + const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; + + return ( + + { + if (canExpand) { + triggerDisclosureFeedback(); + props.onToggleRow(row.id); + } + }} + onLongPress={() => props.onCopyRow(row.id, row.copyText)} + style={({ pressed }) => ({ + backgroundColor: pressed ? pressedBackground : "transparent", + })} + className="rounded-md px-0.5 py-0.5" + > + + + + + + + + {row.summary} + + {row.detail ? ( + {row.detail} + ) : null} + + + + {props.copiedRowId === row.id ? ( + + Copied + + ) : null} + + {canExpand ? ( + + ) : null} + + + {row.status ? ( + + ) : null} + + + + + + {expanded && row.fullDetail ? ( + + + + {row.fullDetail} + + + + ) : null} + + ); + })} + + + {hasOverflow ? ( + { + triggerDisclosureFeedback(); + props.onToggleGroup(); + }} + style={({ pressed }) => ({ + backgroundColor: pressed ? pressedBackground : "transparent", + })} + className="min-h-9 flex-row items-center gap-1.5 rounded-md px-0.5 py-0.5" + > + + + + + {props.expanded + ? "Show fewer tool calls" + : `+${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/threadContentPresentation.test.ts b/apps/mobile/src/features/threads/threadContentPresentation.test.ts new file mode 100644 index 00000000000..f179e756fbf --- /dev/null +++ b/apps/mobile/src/features/threads/threadContentPresentation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { projectThreadContentPresentation } from "./threadContentPresentation"; + +describe("thread content presentation", () => { + it("renders cached detail while its environment reconnects", () => { + expect( + projectThreadContentPresentation({ + hasDetail: true, + detailError: null, + detailDeleted: false, + connectionState: "reconnecting", + }), + ).toEqual({ kind: "ready" }); + }); + + it("loads missing detail inside the thread screen when connected", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: null, + detailDeleted: false, + connectionState: "connected", + }), + ).toEqual({ kind: "loading" }); + }); + + it("explains uncached detail while disconnected instead of loading forever", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: null, + detailDeleted: false, + connectionState: "error", + }), + ).toEqual({ + kind: "unavailable", + title: "Messages not cached", + detail: "Reconnect this environment to load the conversation.", + }); + }); + + it("surfaces detail errors before presenting a loading state", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: "The thread stream failed.", + detailDeleted: false, + connectionState: "connected", + }), + ).toEqual({ + kind: "unavailable", + title: "Could not load conversation", + detail: "The thread stream failed.", + }); + }); +}); diff --git a/apps/mobile/src/features/threads/threadContentPresentation.ts b/apps/mobile/src/features/threads/threadContentPresentation.ts new file mode 100644 index 00000000000..c806e6dfc46 --- /dev/null +++ b/apps/mobile/src/features/threads/threadContentPresentation.ts @@ -0,0 +1,43 @@ +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + +export type ThreadContentPresentation = + | { readonly kind: "ready" } + | { readonly kind: "loading" } + | { + readonly kind: "unavailable"; + readonly title: string; + readonly detail: string; + }; + +export function projectThreadContentPresentation(input: { + readonly hasDetail: boolean; + readonly detailError: string | null; + readonly detailDeleted: boolean; + readonly connectionState: EnvironmentConnectionPhase; +}): ThreadContentPresentation { + if (input.hasDetail) { + return { kind: "ready" }; + } + if (input.detailDeleted) { + return { + kind: "unavailable", + title: "Thread unavailable", + detail: "This thread was deleted or is no longer available.", + }; + } + if (input.detailError !== null) { + return { + kind: "unavailable", + title: "Could not load conversation", + detail: input.detailError, + }; + } + if (input.connectionState === "connected") { + return { kind: "loading" }; + } + return { + kind: "unavailable", + title: "Messages not cached", + detail: "Reconnect this environment to load the conversation.", + }; +} diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index ea934327ace..cf5eb1817a4 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -1,13 +1,12 @@ import type { StatusTone } from "../../components/StatusPill"; -import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; -import { resolveRemoteHttpUrl } from "../../lib/remoteUrl"; +import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -export function threadSortValue(thread: EnvironmentScopedThreadShell): number { +export function threadSortValue(thread: EnvironmentThreadShell): number { const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); return Number.isNaN(candidate) ? 0 : candidate; } -export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTone { +export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone { const status = thread.session?.status; if (status === "running") { return { @@ -43,14 +42,3 @@ export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTo textClassName: "text-neutral-600 dark:text-neutral-300", }; } - -export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string): string | null { - if (!httpBaseUrl) { - return null; - } - - return resolveRemoteHttpUrl({ - httpBaseUrl, - pathname: `/attachments/${encodeURIComponent(attachmentId)}`, - }); -} diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 029e1bbdcf6..9531567f447 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -1,67 +1,27 @@ import { useCallback } from "react"; -import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { CommandId, - DEFAULT_PROVIDER_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, - type EnvironmentId, MessageId, ThreadId, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { uuidv4 } from "../../lib/uuid"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { threadEnvironment } from "../../state/threads"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { environmentRuntimeManager } from "../../state/use-environment-runtime"; -import { vcsRefManager } from "../../state/use-vcs-refs"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; -import { - setPendingConnectionError, - useRemoteEnvironmentState, -} from "../../state/use-remote-environment-registry"; - -function useRefreshRemoteData() { - const { savedConnectionsById } = useRemoteEnvironmentState(); - - return useCallback( - async (environmentIds?: ReadonlyArray) => { - const targets = - environmentIds ?? - Object.values(savedConnectionsById).map((connection) => connection.environmentId); - - await Promise.all( - targets.map(async (environmentId) => { - const client = getEnvironmentClient(environmentId); - if (!client) { - return; - } - - try { - const serverConfig = await client.server.getConfig(); - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - serverConfig, - connectionError: null, - })); - } catch (error) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionError: - error instanceof Error ? error.message : "Failed to refresh remote data.", - })); - } - }), - ); - }, - [savedConnectionsById], - ); -} +import { uuidv4 } from "../../lib/uuid"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; +import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -73,13 +33,12 @@ function deriveThreadTitleFromPrompt(value: string): string { return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; } -export function useProjectActions() { - const { threads } = useRemoteCatalog(); - const refreshRemoteData = useRefreshRemoteData(); +export function useCreateProjectThread() { + const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); - const onCreateThreadWithOptions = useCallback( + return useCallback( async (input: { - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; readonly modelSelection: ModelSelection; readonly envMode: "local" | "worktree"; readonly branch: string | null; @@ -89,174 +48,77 @@ export function useProjectActions() { readonly initialMessageText: string; readonly initialAttachments: ReadonlyArray; }) => { - const client = getEnvironmentClient(input.project.environmentId); - if (!client) { - return null; - } - const metadata = makeTurnCommandMetadata(); const threadId = ThreadId.make(metadata.threadId); - const createdAt = metadata.createdAt; const initialMessageText = input.initialMessageText.trim(); const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); - if (initialMessageText.length === 0) { - return null; - } - if (input.envMode === "worktree" && !input.branch) { - return null; + const validationError = validateProjectThreadCreation({ + environmentId: input.project.environmentId, + projectId: input.project.id, + environmentMode: input.envMode, + branch: input.branch, + initialMessageText, + }); + if (validationError !== null) { + setPendingConnectionError(validationError.message); + return AsyncResult.failure(Cause.fail(validationError)); } const isWorktree = input.envMode === "worktree"; - - await client.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: CommandId.make(metadata.commandId), - threadId, - message: { - messageId: MessageId.make(metadata.messageId), - role: "user", - text: initialMessageText, - attachments: input.initialAttachments, - }, - modelSelection: input.modelSelection, - titleSeed: nextTitle, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - bootstrap: { - createThread: { - projectId: input.project.id, - title: nextTitle, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - branch: input.branch, - worktreePath: isWorktree ? null : input.worktreePath, - createdAt, + const result = await startTurn({ + environmentId: input.project.environmentId, + input: { + commandId: CommandId.make(metadata.commandId), + threadId, + message: { + messageId: MessageId.make(metadata.messageId), + role: "user", + text: initialMessageText, + attachments: input.initialAttachments, }, - ...(isWorktree - ? { - prepareWorktree: { - projectCwd: input.project.workspaceRoot, - baseBranch: input.branch!, - branch: buildTemporaryWorktreeBranchName(uuidv4), - }, - runSetupScript: true, - } - : {}), + modelSelection: input.modelSelection, + titleSeed: nextTitle, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + bootstrap: { + createThread: { + projectId: input.project.id, + title: nextTitle, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: input.branch, + worktreePath: isWorktree ? null : input.worktreePath, + createdAt: metadata.createdAt, + }, + ...(isWorktree + ? { + prepareWorktree: { + projectCwd: input.project.workspaceRoot, + baseBranch: input.branch!, + branch: buildTemporaryWorktreeBranchName(uuidv4), + }, + runSetupScript: true, + } + : {}), + }, + createdAt: metadata.createdAt, }, - createdAt, }); - - await refreshRemoteData([input.project.environmentId]); - return { - environmentId: input.project.environmentId, - threadId, - }; - }, - [refreshRemoteData], - ); - - const onCreateThread = useCallback( - async (project: EnvironmentScopedProjectShell) => { - const latestProjectThread = - threads.find( - (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, - ) ?? null; - const modelSelection = - project.defaultModelSelection ?? latestProjectThread?.modelSelection ?? null; - if (!modelSelection) { - setPendingConnectionError("This project does not have a default model configured yet."); - return null; - } - - return await onCreateThreadWithOptions({ - project, - modelSelection, - envMode: "local", - branch: null, - worktreePath: null, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - initialMessageText: "", - initialAttachments: [], - }); - }, - [onCreateThreadWithOptions, threads], - ); - - const onListProjectBranches = useCallback( - async (project: EnvironmentScopedProjectShell): Promise> => { - const client = getEnvironmentClient(project.environmentId); - if (!client) { - return []; - } - - try { - const result = await vcsRefManager.load( - { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null }, - client.vcs, - { limit: 100 }, - ); - return (result?.refs ?? []).filter((branch) => !branch.isRemote); - } catch (error) { + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); setPendingConnectionError( - error instanceof Error ? error.message : "Failed to load branches.", + error instanceof Error ? error.message : "The task could not be started.", ); - return []; - } - }, - [], - ); - - const onCreateProjectWorktree = useCallback( - async ( - project: EnvironmentScopedProjectShell, - nextWorktree: { - readonly baseBranch: string; - readonly newBranch: string; - }, - ): Promise<{ - readonly branch: string; - readonly worktreePath: string; - } | null> => { - const client = getEnvironmentClient(project.environmentId); - if (!client) { - return null; + return AsyncResult.failure(result.cause); } + setPendingConnectionError(null); - try { - const result = await client.vcs.createWorktree({ - cwd: project.workspaceRoot, - refName: nextWorktree.baseBranch, - newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), - path: null, - }); - vcsRefManager.invalidate({ - environmentId: project.environmentId, - cwd: project.workspaceRoot, - query: null, - }); - return { - branch: result.worktree.refName, - worktreePath: result.worktree.path, - }; - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to create worktree.", - ); - return null; - } + return mapAtomCommandResult(result, () => + scopeThreadRef(input.project.environmentId, threadId), + ); }, - [], + [startTurn], ); - - return { - onCreateThread, - onCreateThreadWithOptions, - onListProjectBranches, - onCreateProjectWorktree, - onRefreshProjects: refreshRemoteData, - }; } diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index b341c7b6bd4..09897b6186e 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,7 +1,7 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; import { Platform } from "react-native"; -export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { +export function authClientMetadata(): AuthClientPresentationMetadata { return { label: "T3 Code Mobile", deviceType: "mobile", diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts new file mode 100644 index 00000000000..a121b70ddb5 --- /dev/null +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -0,0 +1,11 @@ +import * as Schema from "effect/Schema"; + +export const DraftComposerImageAttachmentSchema = Schema.Struct({ + id: Schema.String, + previewUri: Schema.String, + type: Schema.Literal("image"), + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + dataUrl: Schema.String, +}); diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 68813b0b3b1..f1f30b298b6 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -3,13 +3,13 @@ import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, - mobileAuthClientMetadata, + authClientMetadata, redactPairingCredential, toStableSavedRemoteConnection, } from "./connection"; vi.mock("./runtime", () => ({ - mobileRuntime: { + runtime: { runPromise: vi.fn(), }, })); @@ -22,7 +22,7 @@ vi.mock("react-native", () => ({ describe("mobile remote connection records", () => { it("identifies mobile token exchanges for authorized-client presentation", () => { - expect(mobileAuthClientMetadata()).toEqual({ + expect(authClientMetadata()).toEqual({ label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index aa92c6f5d58..839bc70e6d9 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,18 +1,8 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { - bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, -} from "@t3tools/client-runtime"; -import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; -import * as Effect from "effect/Effect"; -import { mobileAuthClientMetadata } from "./authClientMetadata"; -import { mobileRuntime } from "./runtime"; +import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -export { mobileAuthClientMetadata } from "./authClientMetadata"; - -export interface RemoteConnectionInput { - readonly pairingUrl: string; -} +export { authClientMetadata } from "./authClientMetadata"; export interface SavedRemoteConnection { readonly environmentId: EnvironmentId; @@ -27,12 +17,7 @@ export interface SavedRemoteConnection { readonly relayManaged?: true; } -export type RemoteClientConnectionState = - | "idle" - | "connecting" - | "ready" - | "reconnecting" - | "disconnected"; +export type RemoteClientConnectionState = EnvironmentConnectionPhase; export function redactPairingCredential(pairingUrl: string): string { const trimmed = pairingUrl.trim(); @@ -59,38 +44,3 @@ export function toStableSavedRemoteConnection( const { dpopAccessToken: _, ...stableConnection } = connection; return stableConnection; } - -export async function bootstrapRemoteConnection( - input: RemoteConnectionInput, -): Promise { - const target = resolveRemotePairingTarget({ - pairingUrl: input.pairingUrl, - }); - - const { descriptor, bootstrap } = await mobileRuntime.runPromise( - Effect.all( - { - descriptor: fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: target.httpBaseUrl, - }), - bootstrap: bootstrapRemoteBearerSession({ - httpBaseUrl: target.httpBaseUrl, - credential: target.credential, - clientMetadata: mobileAuthClientMetadata(), - }), - }, - { concurrency: "unbounded" }, - ), - ); - - return { - environmentId: descriptor.environmentId, - environmentLabel: descriptor.label, - pairingUrl: redactPairingCredential(input.pairingUrl), - displayUrl: target.httpBaseUrl, - httpBaseUrl: target.httpBaseUrl, - wsBaseUrl: target.wsBaseUrl, - bearerToken: bootstrap.access_token, - authenticationMethod: "bearer", - }; -} diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts index d15a3a1a59b..236fb44cd6b 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ impactAsync: vi.fn(), + selectionAsync: vi.fn(), setStringAsync: vi.fn(), })); @@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({ Light: "light", }, impactAsync: mocks.impactAsync, + selectionAsync: mocks.selectionAsync, })); -import { copyTextWithHaptic } from "./copyTextWithHaptic"; +import { + CopyTextClipboardWriteError, + CopyTextHapticFeedbackError, + copyTextWithHaptic, +} from "./copyTextWithHaptic"; describe("copyTextWithHaptic", () => { beforeEach(() => { vi.clearAllMocks(); mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); mocks.impactAsync.mockResolvedValue(undefined); + mocks.selectionAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("triggers haptic feedback without waiting for the clipboard promise", () => { @@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => { expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); expect(mocks.impactAsync).toHaveBeenCalledWith("light"); }); + + it("preserves selection feedback for thread work rows", () => { + copyTextWithHaptic("work output", { + target: "thread-work-row", + feedback: "selection", + }); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("work output"); + expect(mocks.selectionAsync).toHaveBeenCalledOnce(); + expect(mocks.impactAsync).not.toHaveBeenCalled(); + }); + + it("reports structured failures without including clipboard contents", async () => { + const clipboardCause = new Error("native clipboard failure"); + const hapticCause = new Error("native haptic failure"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); + mocks.impactAsync.mockRejectedValueOnce(hapticCause); + + copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" }); + + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledTimes(2); + }); + + const failures = consoleError.mock.calls.map(([failure]) => failure); + const clipboardError = failures.find( + (failure) => failure instanceof CopyTextClipboardWriteError, + ); + expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError); + expect(clipboardError).toMatchObject({ + target: "connection-trace-id", + cause: clipboardCause, + }); + expect((clipboardError as Error).message).not.toContain("secret clipboard contents"); + + const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError); + expect(hapticError).toBeInstanceOf(CopyTextHapticFeedbackError); + expect(hapticError).toMatchObject({ + target: "connection-trace-id", + feedback: "light-impact", + cause: hapticCause, + }); + expect((hapticError as Error).message).not.toContain("secret clipboard contents"); + }); }); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts index 80f725f5b00..1cc8c94eef7 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -1,7 +1,70 @@ +import * as Schema from "effect/Schema"; import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; -export function copyTextWithHaptic(value: string): void { - void Clipboard.setStringAsync(value); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +export class CopyTextClipboardWriteError extends Schema.TaggedErrorClass()( + "CopyTextClipboardWriteError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.target} to the clipboard.`; + } +} + +export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass()( + "CopyTextHapticFeedbackError", + { + target: Schema.String, + feedback: Schema.Literals(["light-impact", "selection"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to trigger ${this.feedback} haptic feedback after copying ${this.target}.`; + } +} + +export function copyTextWithHaptic( + value: string, + options: { + readonly target?: string; + readonly feedback?: "light-impact" | "selection"; + } = {}, +): void { + const target = options.target ?? "text"; + const feedback = options.feedback ?? "light-impact"; + + void (async () => { + try { + await Clipboard.setStringAsync(value); + } catch (cause) { + console.error( + new CopyTextClipboardWriteError({ + target, + cause, + }), + ); + } + })(); + + void (async () => { + try { + if (feedback === "selection") { + await Haptics.selectionAsync(); + } else { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + } catch (cause) { + console.error( + new CopyTextHapticFeedbackError({ + target, + feedback, + cause, + }), + ); + } + })(); } diff --git a/apps/mobile/src/lib/mobileLayout.ts b/apps/mobile/src/lib/layout.ts similarity index 74% rename from apps/mobile/src/lib/mobileLayout.ts rename to apps/mobile/src/lib/layout.ts index 0ae284e463f..2ae4314fdba 100644 --- a/apps/mobile/src/lib/mobileLayout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -2,19 +2,16 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -export type MobileLayoutVariant = "compact" | "split"; +export type LayoutVariant = "compact" | "split"; -export interface MobileLayout { - readonly variant: MobileLayoutVariant; +export interface Layout { + readonly variant: LayoutVariant; readonly usesSplitView: boolean; readonly listPaneWidth: number | null; readonly shellPadding: number; } -export function deriveMobileLayout(input: { - readonly width: number; - readonly height: number; -}): MobileLayout { +export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { const { width, height } = input; const shortestEdge = Math.min(width, height); const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index 90153d0afa0..ff57287b741 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -16,26 +16,47 @@ describe("resolveMarkdownLinkPresentation", () => { resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), ).toEqual({ kind: "file", + href: "file:///Users/julius/project/src/main.ts#L42C7", icon: "typescript", label: "main.ts:42:7", + path: "/Users/julius/project/src/main.ts", + line: 42, + column: 7, }); }); it("recognizes relative source paths and bare filenames", () => { expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ kind: "file", + href: "apps/mobile/src/index.ts:10", icon: "typescript", label: "index.ts:10", + path: "apps/mobile/src/index.ts", + line: 10, }); expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({ kind: "file", + href: "AGENTS.md", icon: "agents", label: "AGENTS.md", + path: "AGENTS.md", }); expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ kind: "file", + href: "package.json", icon: "package", label: "package.json", + path: "package.json", + }); + }); + + it("extracts line fragments from relative file links", () => { + expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({ + kind: "file", + path: "src/main.ts", + line: 18, + column: 2, + label: "main.ts:18:2", }); }); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts new file mode 100644 index 00000000000..9a71640b45a --- /dev/null +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; + +import { buildModelOptions } from "./modelOptions"; + +describe("mobile model options", () => { + it("normalizes a legacy fallback selection against current capabilities", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + currentValue: "default", + }, + ], + }, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const [option] = buildModelOptions(config, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-test", + options: [{ id: "fastMode", value: true }], + }); + + expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); + expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 778e5bfb5b5..e21682414d7 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,4 +1,12 @@ -import type { ModelSelection, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; +import type { + ModelCapabilities, + ModelSelection, + ServerConfig as T3ServerConfig, +} from "@t3tools/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; export type ModelOption = { readonly key: string; @@ -7,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -27,6 +36,27 @@ function providerDisplayLabel(provider: { return provider.instanceId; } +function normalizeSelectionOptions( + selection: ModelSelection, + capabilities: ModelCapabilities | null, +): ModelSelection { + if (!capabilities) { + return selection; + } + const options = buildProviderOptionSelectionsFromDescriptors( + getProviderOptionDescriptors({ + caps: capabilities, + selections: selection.options, + }), + ); + return options + ? { ...selection, options } + : { + instanceId: selection.instanceId, + model: selection.model, + }; +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -48,17 +78,27 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, - selection: { - instanceId: provider.instanceId, - model: model.slug, - }, + capabilities: model.capabilities, + selection: normalizeSelectionOptions( + { + instanceId: provider.instanceId, + model: model.slug, + }, + model.capabilities, + ), }); } } if (fallbackModelSelection) { const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; - if (!options.has(key)) { + const existing = options.get(key); + if (existing) { + options.set(key, { + ...existing, + selection: normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), + }); + } else { const providerLabel = fallbackModelSelection.instanceId; options.set(key, { key, @@ -67,6 +107,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, + capabilities: null, selection: fallbackModelSelection, }); } diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 9d5c55686ec..6e41f2243a9 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -7,6 +7,7 @@ import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks, nativeMarkdownTextRuns, + nativeMarkdownWithPreservedSoftBreaks, } from "@t3tools/mobile-markdown-text/markdown"; describe("nativeMarkdownTextRuns", () => { @@ -54,7 +55,11 @@ describe("nativeMarkdownTextRuns", () => { externalHost: "example.com", }, { text: " " }, - { text: "README.md:12", fileIcon: "readme" }, + { + text: "README.md:12", + href: "file:///repo/README.md#L12", + fileIcon: "readme", + }, ]); }); @@ -73,6 +78,21 @@ describe("nativeMarkdownTextRuns", () => { expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]); }); + it("can preserve soft breaks for authored user messages", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "first" }, + { type: "soft_break" }, + { type: "text", content: "second" }, + ], + }; + + expect(nativeMarkdownTextRuns(nativeMarkdownWithPreservedSoftBreaks(node))).toEqual([ + { text: "first\nsecond" }, + ]); + }); + it("normalizes common inline HTML and entities", () => { const node: MarkdownNode = { type: "paragraph", @@ -130,7 +150,7 @@ describe("nativeMarkdownTextRuns", () => { }); describe("nativeMarkdownDocumentRuns", () => { - it("decorates known skill references as selectable skill chips", () => { + it("decorates known skill references as selectable skill links", () => { const node: MarkdownNode = { type: "document", children: [ diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts new file mode 100644 index 00000000000..5a69cbdd43b --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -0,0 +1,58 @@ +import { Linking } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { tryOpenExternalUrl } from "./openExternalUrl"; + +vi.mock("react-native", () => ({ + Linking: { openURL: vi.fn() }, +})); + +const openURL = vi.mocked(Linking.openURL); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("tryOpenExternalUrl", () => { + it("opens supported URLs", async () => { + openURL.mockResolvedValue(undefined); + + await expect( + tryOpenExternalUrl("https://github.com/pingdotgg/t3code", "pull-request"), + ).resolves.toBe(true); + }); + + it("logs stable URL context without exposing the opening failure", async () => { + const cause = new Error("browser-unavailable-secret-sentinel"); + openURL.mockRejectedValue(cause); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect( + tryOpenExternalUrl("https://github.com/pingdotgg/t3code/pull/1?token=secret", "pull-request"), + ).resolves.toBe(false); + + expect(consoleError).toHaveBeenCalledTimes(1); + const [message, attributes] = consoleError.mock.calls[0] ?? []; + expect(message).toBe("Failed to open pull-request URL with the https scheme."); + expect(attributes).toEqual( + expect.objectContaining({ + _tag: "ExternalUrlOpenError", + target: "pull-request", + scheme: "https", + host: "github.com", + stack: expect.stringContaining("ExternalUrlOpenError"), + }), + ); + expect(attributes).not.toHaveProperty("url"); + expect(attributes).not.toHaveProperty("cause"); + const diagnosticText = [message, ...Object.values(attributes as Record)] + .map(String) + .join("\n"); + expect(diagnosticText).not.toContain("token=secret"); + expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel"); + }); +}); diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts new file mode 100644 index 00000000000..10e6378bc00 --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -0,0 +1,51 @@ +import * as Schema from "effect/Schema"; +import { Linking } from "react-native"; + +const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]); + +export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; + +export class ExternalUrlOpenError extends Schema.TaggedErrorClass()( + "ExternalUrlOpenError", + { + target: ExternalUrlTarget, + scheme: Schema.String, + host: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open ${this.target} URL with the ${this.scheme} scheme.`; + } +} + +function externalUrlMetadata(url: string): { readonly scheme: string; readonly host?: string } { + try { + const parsed = new URL(url); + return { + scheme: parsed.protocol.replace(/:$/, "") || "unknown", + host: parsed.hostname || undefined, + }; + } catch { + return { + scheme: /^([a-z][a-z\d+.-]*):/i.exec(url)?.[1]?.toLowerCase() ?? "unknown", + }; + } +} + +export async function tryOpenExternalUrl(url: string, target: ExternalUrlTarget): Promise { + try { + await Linking.openURL(url); + return true; + } catch (cause) { + const error = new ExternalUrlOpenError({ target, ...externalUrlMetadata(url), cause }); + console.error(error.message, { + _tag: error._tag, + target: error.target, + scheme: error.scheme, + host: error.host, + stack: error.stack, + }); + return false; + } +} diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts new file mode 100644 index 00000000000..d7f99a3dab7 --- /dev/null +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { ModelCapabilities } from "@t3tools/contracts"; + +import { + applyProviderOptionMenuEvent, + buildProviderOptionMenuActions, + providerOptionsConfigurationLabel, + resolveProviderOptionDescriptors, +} from "./providerOptions"; + +const CODEX_CAPABILITIES: ModelCapabilities = { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + currentValue: "default", + }, + ], +}; + +describe("mobile provider options", () => { + it("renders the option descriptors advertised by the selected model", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + + expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ + { + title: "Reasoning", + subtitle: "Medium", + subactions: [ + { title: "Medium (default)", state: "on" }, + { title: "High", state: undefined }, + ], + }, + { + title: "Service Tier", + subtitle: "Standard", + subactions: [ + { title: "Standard (default)", state: "on" }, + { title: "Fast", state: undefined }, + ], + }, + ]); + expect(providerOptionsConfigurationLabel(descriptors)).toBe("Medium · Standard"); + }); + + it("updates generic select options without knowing provider-specific ids", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + const actions = buildProviderOptionMenuActions(descriptors); + const fastEvent = actions[1]?.subactions?.[1]?.id; + + expect(fastEvent).toBeDefined(); + expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ + { id: "reasoningEffort", value: "medium" }, + { id: "serviceTier", value: "priority" }, + ]); + }); + + it("treats an unspecified boolean capability as off", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: { + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + }, + selections: undefined, + }); + + expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ + { + title: "Fast Mode", + subtitle: "Off", + subactions: [ + { title: "Off", state: "on" }, + { title: "On", state: undefined }, + ], + }, + ]); + expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); + }); +}); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts new file mode 100644 index 00000000000..ae195498962 --- /dev/null +++ b/apps/mobile/src/lib/providerOptions.ts @@ -0,0 +1,141 @@ +import type { + ModelCapabilities, + ProviderOptionDescriptor, + ProviderOptionSelection, +} from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; + +const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:"; + +function providerOptionEvent(id: string, value: string | boolean): string { + return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`; +} + +function parseProviderOptionEvent( + event: string, +): { readonly id: string; readonly value: string | boolean } | null { + if (!event.startsWith(PROVIDER_OPTION_EVENT_PREFIX)) { + return null; + } + + try { + const parsed: unknown = JSON.parse( + decodeURIComponent(event.slice(PROVIDER_OPTION_EVENT_PREFIX.length)), + ); + if ( + typeof parsed === "object" && + parsed !== null && + "id" in parsed && + typeof parsed.id === "string" && + "value" in parsed && + (typeof parsed.value === "string" || typeof parsed.value === "boolean") + ) { + return { id: parsed.id, value: parsed.value }; + } + } catch { + return null; + } + + return null; +} + +export function resolveProviderOptionDescriptors(input: { + readonly capabilities: ModelCapabilities | null | undefined; + readonly selections: ReadonlyArray | null | undefined; +}): ReadonlyArray { + if (!input.capabilities) { + return []; + } + return getProviderOptionDescriptors({ + caps: input.capabilities, + selections: input.selections, + }); +} + +export function buildProviderOptionMenuActions( + descriptors: ReadonlyArray, +): ReadonlyArray { + return descriptors.map((descriptor) => { + const currentValue = + descriptor.type === "boolean" + ? (descriptor.currentValue ?? false) + : getProviderOptionCurrentValue(descriptor); + const choices = + descriptor.type === "select" + ? descriptor.options.map((option) => ({ + id: providerOptionEvent(descriptor.id, option.id), + title: `${option.label}${option.isDefault ? " (default)" : ""}`, + state: currentValue === option.id ? ("on" as const) : undefined, + })) + : ([false, true] as const).map((value) => ({ + id: providerOptionEvent(descriptor.id, value), + title: value ? "On" : "Off", + state: currentValue === value ? ("on" as const) : undefined, + })); + + return { + id: `provider-option-menu:${descriptor.id}`, + title: descriptor.label, + subtitle: + descriptor.type === "boolean" + ? currentValue + ? "On" + : "Off" + : getProviderOptionCurrentLabel(descriptor), + subactions: choices, + }; + }); +} + +export function providerOptionsConfigurationLabel( + descriptors: ReadonlyArray, +): string { + const labels = descriptors.flatMap((descriptor) => { + if (descriptor.type === "boolean") { + return descriptor.currentValue ? [descriptor.label] : []; + } + const label = getProviderOptionCurrentLabel(descriptor); + return label ? [label] : []; + }); + return labels.length > 0 ? labels.join(" · ") : "Configuration"; +} + +export function applyProviderOptionMenuEvent( + descriptors: ReadonlyArray, + event: string, +): ReadonlyArray | null { + const selection = parseProviderOptionEvent(event); + if (!selection) { + return null; + } + + const descriptor = descriptors.find((candidate) => candidate.id === selection.id); + if (!descriptor) { + return null; + } + if ( + (descriptor.type === "boolean" && typeof selection.value !== "boolean") || + (descriptor.type === "select" && + (typeof selection.value !== "string" || + !descriptor.options.some((option) => option.id === selection.value))) + ) { + return null; + } + + const nextDescriptors = descriptors.map((candidate) => + candidate.id === descriptor.id + ? { + ...candidate, + currentValue: selection.value, + } + : candidate, + ) as ReadonlyArray; + + return buildProviderOptionSelectionsFromDescriptors(nextDescriptors) ?? []; +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 2a8db041b97..3aa6e494991 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -3,15 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { groupProjectsByRepository } from "./repositoryGroups"; -import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, -} from "@t3tools/client-runtime"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; function makeProject( - input: Partial & - Pick, -): EnvironmentScopedProjectShell { + input: Partial & Pick, +): EnvironmentProject { return { workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, @@ -24,12 +20,9 @@ function makeProject( } function makeThread( - input: Partial & - Pick< - EnvironmentScopedThreadShell, - "environmentId" | "id" | "projectId" | "title" | "modelSelection" - >, -): EnvironmentScopedThreadShell { + input: Partial & + Pick, +): EnvironmentThreadShell { return { runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/mobile/src/lib/repositoryGroups.ts b/apps/mobile/src/lib/repositoryGroups.ts index 5238411a643..bf4c2f3fccd 100644 --- a/apps/mobile/src/lib/repositoryGroups.ts +++ b/apps/mobile/src/lib/repositoryGroups.ts @@ -3,21 +3,18 @@ import * as Arr from "effect/Array"; import type { RepositoryIdentity } from "@t3tools/contracts"; import { scopedProjectKey } from "./scopedEntities"; -import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, -} from "@t3tools/client-runtime"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; const DateDescending = Order.flip(Order.Date); -export interface MobileRepositoryProjectGroup { +export interface RepositoryProjectGroup { readonly key: string; - readonly project: EnvironmentScopedProjectShell; - readonly threads: ReadonlyArray; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; readonly latestActivityAt: string; } -export interface MobileRepositoryGroup { +export interface RepositoryGroup { readonly key: string; readonly title: string; readonly subtitle: string | null; @@ -25,20 +22,20 @@ export interface MobileRepositoryGroup { readonly projectCount: number; readonly threadCount: number; readonly latestActivityAt: string; - readonly projects: ReadonlyArray; + readonly projects: ReadonlyArray; } function compareIsoDateDescending(left: string, right: string): number { return new Date(right).getTime() - new Date(left).getTime(); } -function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string { +function deriveRepositoryGroupKey(project: EnvironmentProject): string { return ( project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) ); } -function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string { +function deriveRepositoryTitle(project: EnvironmentProject): string { const identity = project.repositoryIdentity; return identity?.displayName ?? identity?.name ?? project.title; } @@ -54,18 +51,18 @@ function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefine } function deriveProjectLatestActivity( - project: EnvironmentScopedProjectShell, - threads: ReadonlyArray, + project: EnvironmentProject, + threads: ReadonlyArray, ): string { const latestThread = threads[0]; return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; } export function groupProjectsByRepository(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; -}): ReadonlyArray { - const threadsByProjectKey = new Map(); + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + const threadsByProjectKey = new Map(); for (const thread of input.threads) { const key = scopedProjectKey(thread.environmentId, thread.projectId); @@ -77,7 +74,7 @@ export function groupProjectsByRepository(input: { } } - const grouped = new Map(); + const grouped = new Map(); for (const project of input.projects) { const key = deriveRepositoryGroupKey(project); @@ -89,7 +86,7 @@ export function groupProjectsByRepository(input: { ); const latestActivityAt = deriveProjectLatestActivity(project, threads); - const projectGroup: MobileRepositoryProjectGroup = { + const projectGroup: RepositoryProjectGroup = { key: projectKey, project, threads, diff --git a/apps/mobile/src/lib/routes.test.ts b/apps/mobile/src/lib/routes.test.ts new file mode 100644 index 00000000000..773de9d84f7 --- /dev/null +++ b/apps/mobile/src/lib/routes.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { buildThreadFilesNavigation, buildThreadFilesRoutePath } from "./routes"; + +const thread = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("thread file routes", () => { + it("includes an optional source line in string routes", () => { + expect(buildThreadFilesRoutePath(thread, "src/main.ts", 42)).toBe( + "/threads/environment-1/thread-1/files/src/main.ts?line=42", + ); + }); + + it("encodes each file path segment without encoding separators", () => { + expect(buildThreadFilesRoutePath(thread, "docs/My File#1.md")).toBe( + "/threads/environment-1/thread-1/files/docs/My%20File%231.md", + ); + }); + + it("builds typed navigation params for a file and source line", () => { + expect(buildThreadFilesNavigation(thread, "src/main.ts", 42)).toEqual({ + pathname: "/threads/[environmentId]/[threadId]/files/[...path]", + params: { + environmentId: "environment-1", + threadId: "thread-1", + path: ["src", "main.ts"], + line: "42", + }, + }); + }); + + it("targets the files index when no file path is provided", () => { + expect(buildThreadFilesNavigation(thread)).toEqual({ + pathname: "/threads/[environmentId]/[threadId]/files", + params: { + environmentId: "environment-1", + threadId: "thread-1", + }, + }); + }); +}); diff --git a/apps/mobile/src/lib/routes.ts b/apps/mobile/src/lib/routes.ts index bf49a20ac41..3a33e2ee0f9 100644 --- a/apps/mobile/src/lib/routes.ts +++ b/apps/mobile/src/lib/routes.ts @@ -1,5 +1,5 @@ import type { Href, useRouter } from "expo-router"; -import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SelectedThreadRef } from "../state/remote-runtime-types"; @@ -8,7 +8,7 @@ type Router = ReturnType; type ThreadRouteInput = | Pick - | Pick; + | Pick; type PlainThreadRouteInput = | { environmentId: EnvironmentId; @@ -32,6 +32,27 @@ export function buildThreadReviewRoutePath( return `${buildThreadRoutePath(input)}/review`; } +export function buildThreadFilesRoutePath( + input: ThreadRouteInput | PlainThreadRouteInput, + relativePath?: string | null, + line?: number | null, +): string { + const basePath = `${buildThreadRoutePath(input)}/files`; + if (!relativePath) { + return basePath; + } + + const pathSegments = relativePath.split("/").filter((segment) => segment.length > 0); + if (pathSegments.length === 0) { + return basePath; + } + + const encodedPath = pathSegments.map(encodeURIComponent).join("/"); + const lineParam = + Number.isFinite(line) && Number(line) > 0 ? `?line=${Math.floor(Number(line))}` : ""; + return `${basePath}/${encodedPath}${lineParam}`; +} + export function buildThreadTerminalRoutePath( input: ThreadRouteInput | PlainThreadRouteInput, terminalId?: string | null, @@ -71,6 +92,38 @@ export function buildThreadTerminalNavigation( }; } +export function buildThreadFilesNavigation( + input: ThreadRouteInput | PlainThreadRouteInput, + relativePath?: string | null, + line?: number | null, +): Href { + const environmentId = String(input.environmentId); + const threadId = String("threadId" in input ? input.threadId : input.id); + const path = relativePath?.split("/").filter((segment) => segment.length > 0) ?? []; + + if (path.length === 0) { + return { + pathname: "/threads/[environmentId]/[threadId]/files", + params: { environmentId, threadId }, + }; + } + + const params: { + environmentId: string; + threadId: string; + path: string[]; + line?: string; + } = { environmentId, threadId, path }; + if (Number.isFinite(line) && Number(line) > 0) { + params.line = String(Math.floor(Number(line))); + } + + return { + pathname: "/threads/[environmentId]/[threadId]/files/[...path]", + params, + }; +} + export function dismissRoute(router: Router) { if (router.canGoBack()) { router.back(); diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index ce37a41e8ab..51a4885562c 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,25 +1,42 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Socket from "effect/unstable/socket/Socket"; -import { remoteHttpClientLayer } from "@t3tools/client-runtime"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; -import { mobileCryptoLayer } from "../features/cloud/dpop"; -import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; +import { cryptoLayer } from "../features/cloud/dpop"; +import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; -import { mobileTracingLayer } from "../features/observability/mobileTracing"; +import { tracingLayer } from "../features/observability/tracing"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; } -const mobileHttpClientLayer = remoteHttpClientLayer(fetch); +const httpClientLayer = remoteHttpClientLayer(fetch); -export const mobileRuntime = ManagedRuntime.make( - mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( - Layer.provideMerge(mobileCryptoLayer), - Layer.provideMerge(mobileHttpClientLayer), - Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))), - ), +type RuntimeLayerSource = + | ReturnType + | typeof Socket.layerWebSocketConstructorGlobal + | typeof cryptoLayer + | typeof httpClientLayer + | typeof tracingLayer; + +const runtimeLayer = Layer.merge( + managedRelayClientLayer(configuredRelayUrl()), + Socket.layerWebSocketConstructorGlobal, +).pipe( + Layer.provideMerge(cryptoLayer), + Layer.provideMerge(httpClientLayer), + Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), ); -export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect); +export const runtime: ManagedRuntime.ManagedRuntime< + Layer.Success, + Layer.Error +> = ManagedRuntime.make(runtimeLayer); + +export const runtimeContextLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 83ff2db5748..084f9430d08 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -25,7 +25,7 @@ vi.mock("react-native", () => ({ })); vi.mock("./runtime", () => ({ - mobileRuntime: { + runtime: { runPromise: vi.fn(), }, })); @@ -69,4 +69,35 @@ describe("mobile connection storage", () => { toStableSavedRemoteConnection(managedConnection), ]); }); + + it("preserves secure-storage read failures with operation and key context", async () => { + const cause = new Error("keychain unavailable"); + mocks.getItemAsync.mockRejectedValueOnce(cause); + + await expect(loadSavedConnections()).rejects.toMatchObject({ + _tag: "MobileSecureStorageError", + operation: "read", + key: "t3code.connections", + cause, + message: "Mobile secure storage operation read failed for key t3code.connections.", + }); + }); + + it("logs structured decode failures before using the empty fallback", async () => { + await mocks.setItemAsync("t3code.connections", "{"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadSavedConnections()).resolves.toEqual([]); + expect(warn).toHaveBeenCalledWith( + "[mobile-storage] ignored invalid JSON", + expect.objectContaining({ + _tag: "MobileStorageDecodeError", + key: "t3code.connections", + cause: expect.any(SyntaxError), + message: "Failed to decode mobile storage value for key t3code.connections.", + }), + ); + + warn.mockRestore(); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 2f9e4962c1a..114648277b9 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -1,9 +1,8 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; -import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, @@ -14,119 +13,96 @@ import { const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; -const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; -const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; - -export interface CachedShellSnapshot { - readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION; - readonly environmentId: EnvironmentId; - readonly snapshotReceivedAt: string; - readonly snapshot: OrchestrationShellSnapshot; +const MobileStorageKey = Schema.Literals([ + CONNECTIONS_KEY, + PREFERENCES_KEY, + AGENT_AWARENESS_DEVICE_ID_KEY, +]); +type MobileStorageKeyValue = typeof MobileStorageKey.Type; + +export class MobileSecureStorageError extends Schema.TaggedErrorClass()( + "MobileSecureStorageError", + { + operation: Schema.Literals(["read", "write", "generate-device-id"]), + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; + } } -export interface MobilePreferences { - readonly liveActivitiesEnabled?: boolean; - readonly terminalFontSize?: number; +export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( + "MobileStorageDecodeError", + { + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode mobile storage value for key ${this.key}.`; + } } -const CachedShellSnapshotSchema = Schema.Struct({ - schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - snapshotReceivedAt: Schema.String, - snapshot: OrchestrationShellSnapshot, -}); -const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema); - -async function readStorageItem(key: string): Promise { - return await SecureStore.getItemAsync(key); +export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( + "MobileStorageEncodeError", + { + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode mobile storage value for key ${this.key}.`; + } } -async function writeStorageItem(key: string, value: string): Promise { - await SecureStore.setItemAsync(key, value); +export interface Preferences { + readonly liveActivitiesEnabled?: boolean; + readonly terminalFontSize?: number; } -async function readJsonStorageItem(key: string): Promise { - const raw = (await readStorageItem(key)) ?? ""; - if (!raw.trim()) { - return null; - } - +async function readStorageItem(key: MobileStorageKeyValue): Promise { try { - return JSON.parse(raw) as T; - } catch { - return null; + return await SecureStore.getItemAsync(key); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "read", key, cause }); } } -function cachedShellSnapshotFileName(environmentId: EnvironmentId): string { - return `${encodeURIComponent(environmentId)}.json`; -} - -async function getShellSnapshotCacheDirectory() { - const { Directory, Paths } = await import("expo-file-system"); - const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); - return directory; +async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { + try { + await SecureStore.setItemAsync(key, value); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "write", key, cause }); + } } -export async function loadCachedShellSnapshot( - environmentId: EnvironmentId, -): Promise { - try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - if (!file.exists) { - return null; - } - - const parsed = JSON.parse(await file.text()) as unknown; - const decoded = decodeCachedShellSnapshot(parsed); - if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) { - return null; - } - - return decoded.value; - } catch { +async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { + const raw = (await readStorageItem(key)) ?? ""; + if (!raw.trim()) { return null; } -} -export async function saveCachedShellSnapshot( - environmentId: EnvironmentId, - snapshot: OrchestrationShellSnapshot, -): Promise { try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - const document: CachedShellSnapshot = { - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshotReceivedAt: new Date().toISOString(), - snapshot, - }; - - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(document)); - } catch { - // Cache persistence is best-effort and should never block live data. + return JSON.parse(raw) as T; + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key, cause }), + ); + return null; } } -export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise { +async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { + let encoded: string; try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - if (file.exists) { - file.delete(); - } - } catch { - // Ignore cache cleanup failures. + encoded = JSON.stringify(value); + } catch (cause) { + throw new MobileStorageEncodeError({ key, cause }); } + await writeStorageItem(key, encoded); } export async function loadSavedConnections(): Promise> { @@ -157,7 +133,7 @@ export async function saveConnection(connection: SavedRemoteConnection): Promise ) : pipe(current, Arr.append(stableConnection)); - await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); + await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } export async function clearSavedConnection(environmentId: EnvironmentId): Promise { @@ -166,11 +142,11 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis current, Arr.filter((entry) => entry.environmentId !== environmentId), ); - await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); + await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } -export async function loadPreferences(): Promise { - const parsed = await readJsonStorageItem(PREFERENCES_KEY); +export async function loadPreferences(): Promise { + const parsed = await readJsonStorageItem(PREFERENCES_KEY); if (!parsed || typeof parsed !== "object") { return {}; } @@ -190,15 +166,13 @@ export async function loadPreferences(): Promise { return preferences; } -export async function savePreferencesPatch( - patch: Partial, -): Promise { +export async function savePreferencesPatch(patch: Partial): Promise { const current = await loadPreferences(); - const next: MobilePreferences = { + const next: Preferences = { ...current, ...patch, }; - await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); + await writeJsonStorageItem(PREFERENCES_KEY, next); return next; } @@ -208,8 +182,15 @@ export async function loadOrCreateAgentAwarenessDeviceId(): Promise { return existing; } - const { uuidv4 } = await import("./uuid"); - const deviceId = uuidv4(); + const deviceId = await import("./uuid") + .then(({ uuidv4 }) => uuidv4()) + .catch((cause) => { + throw new MobileSecureStorageError({ + operation: "generate-device-id", + key: AGENT_AWARENESS_DEVICE_ID_KEY, + cause, + }); + }); await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); return deviceId; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 307afb9e3d2..12bafffb58b 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -87,7 +87,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); expect(feed).toMatchObject([ { type: "activity-group", @@ -145,7 +145,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const group = feed[0]; expect(group).toMatchObject({ @@ -162,14 +162,65 @@ describe("buildThreadFeed", () => { turnId: "turn-1", summary: "Run tests", detail: "bun run test", - fullDetail: null, - copyText: "Run tests\nbun run test", + fullDetail: "/bin/zsh -lc 'bun run test'", + copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'", + icon: "command", toolLike: true, status: "success", }, ]); }); + it("keeps MCP inputs available to expanded mobile work rows", () => { + const turnId = TurnId.make("turn-mcp"); + const thread = makeThread({ + id: ThreadId.make("thread-mcp"), + projectId: ProjectId.make("project-1"), + title: "Expandable MCP call", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("mcp-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Call repository tool", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + title: "Call repository tool", + itemType: "mcp_tool_call", + detail: "repository.search", + status: "completed", + data: { + item: { + server: "repository", + tool: "search", + arguments: { query: "work log" }, + }, + }, + }, + }), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group).toMatchObject({ type: "activity-group" }); + if (!group || group.type !== "activity-group") { + return; + } + + expect(group.activities[0]?.icon).toBe("wrench"); + expect(group.activities[0]?.fullDetail).toContain('"query": "work log"'); + expect(group.activities[0]?.fullDetail).toContain("repository.search"); + }); + it("folds settled turn work while leaving the terminal answer visible", () => { const turnId = TurnId.make("turn-1"); const thread = makeThread({ @@ -221,7 +272,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); expect(collapsed[0]).toMatchObject({ @@ -309,7 +360,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ turnId: firstTurnId, @@ -349,7 +400,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); expect(feed[0]).toMatchObject({ type: "activity-group", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e5fdb439954..f6008057a0e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,19 +1,14 @@ import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; import type { - CommandId, - EnvironmentId, - MessageId, OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, ToolLifecycleItemType, - ThreadId, TurnId, UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import type { DraftComposerImageAttachment } from "./composerImages"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -35,16 +30,6 @@ export interface PendingUserInputDraftAnswer { readonly customAnswer?: string; } -export interface QueuedThreadMessage { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly messageId: MessageId; - readonly commandId: CommandId; - readonly text: string; - readonly attachments: ReadonlyArray; - readonly createdAt: string; -} - export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; @@ -53,6 +38,19 @@ export interface ThreadFeedActivity { readonly detail: string | null; readonly fullDetail: string | null; readonly copyText: string; + readonly icon: + | "agent" + | "alert" + | "check" + | "command" + | "edit" + | "eye" + | "globe" + | "hammer" + | "message" + | "warning" + | "wrench" + | "zap"; readonly toolLike: boolean; readonly status: "success" | "failure" | "neutral" | null; } @@ -73,6 +71,7 @@ interface WorkLogEntry { itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; + toolData?: unknown; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -87,13 +86,6 @@ type RawThreadFeedEntry = readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; } - | { - readonly type: "queued-message"; - readonly id: string; - readonly createdAt: string; - readonly queuedMessage: QueuedThreadMessage; - readonly sending: boolean; - } | { readonly type: "activity"; readonly id: string; @@ -103,7 +95,7 @@ type RawThreadFeedEntry = }; export type ThreadFeedEntry = - | Extract + | Extract | { readonly type: "activity-group"; readonly id: string; @@ -227,7 +219,7 @@ function resolvePendingUserInputAnswer( function deriveWorkLogEntries( activities: ReadonlyArray, -): WorkLogEntry[] { +): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { @@ -238,9 +230,7 @@ function deriveWorkLogEntries( if (isPlanBoundaryToolActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } - return collapseDerivedWorkLogEntries(entries).map( - ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry, - ); + return collapseDerivedWorkLogEntries(entries); } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { @@ -314,6 +304,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } + if (itemType === "mcp_tool_call") { + const data = asRecord(payload?.data); + if (data?.item !== undefined) { + entry.toolData = data.item; + } + } if (itemType) { entry.itemType = itemType; } @@ -378,6 +374,7 @@ function mergeDerivedWorkLogEntries( const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; + const toolData = next.toolData ?? previous.toolData; return { ...previous, ...next, @@ -390,6 +387,7 @@ function mergeDerivedWorkLogEntries( ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), + ...(toolData !== undefined ? { toolData } : {}), }; } @@ -493,6 +491,52 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { return "neutral"; } +function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { + if ( + entry.activityKind === "user-input.requested" || + entry.activityKind === "user-input.resolved" + ) { + return "message"; + } + if (entry.activityKind === "runtime.warning") return "warning"; + if (entry.requestKind === "command") return "command"; + if (entry.requestKind === "file-read") return "eye"; + if (entry.requestKind === "file-change") return "edit"; + if (entry.itemType === "command_execution" || entry.command) return "command"; + if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; + if (entry.itemType === "web_search") return "globe"; + if (entry.itemType === "image_view") return "eye"; + if (entry.itemType === "mcp_tool_call") return "wrench"; + if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") { + return "hammer"; + } + if (entry.tone === "error") return "alert"; + if (entry.tone === "thinking") return "agent"; + if (entry.tone === "info") return "check"; + return "zap"; +} + +function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { + const blocks: string[] = []; + const appendUniqueBlock = (value: string | null | undefined) => { + const trimmed = value?.trim(); + if (trimmed && !blocks.includes(trimmed)) { + blocks.push(trimmed); + } + }; + + if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { + appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); + } + appendUniqueBlock(entry.rawCommand ?? entry.command); + appendUniqueBlock(entry.detail); + if ((entry.changedFiles?.length ?? 0) > 0) { + appendUniqueBlock(entry.changedFiles!.join("\n")); + } + + return blocks.length > 0 ? blocks.join("\n\n") : null; +} + function workEntryPreview( workEntry: Pick, ): string | null { @@ -1202,8 +1246,6 @@ export function buildPendingUserInputAnswers( export function buildThreadFeed( thread: OrchestrationThread, - queuedMessages: ReadonlyArray, - dispatchingQueuedMessageId: MessageId | null, options?: { readonly loadedMessages?: ReadonlyArray; }, @@ -1220,13 +1262,6 @@ export function buildThreadFeed( createdAt: message.createdAt, message, })), - ...queuedMessages.map((queuedMessage) => ({ - type: "queued-message", - id: queuedMessage.messageId, - createdAt: queuedMessage.createdAt, - queuedMessage, - sending: queuedMessage.messageId === dispatchingQueuedMessageId, - })), ...workLogEntries .filter((entry) => { if (options?.loadedMessages === undefined) { @@ -1239,11 +1274,7 @@ export function buildThreadFeed( .map((entry) => { const summary = workEntryHeading(entry); const detail = workEntryPreview(entry); - const normalizedFullDetail = entry.detail - ? unwrapKnownShellCommandWrapper(entry.detail) - : null; - const fullDetail = - normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null; + const fullDetail = buildWorkEntryExpandedBody(entry); return { type: "activity", id: entry.id, @@ -1256,6 +1287,7 @@ export function buildThreadFeed( summary, detail, fullDetail, + icon: workEntryIcon(entry), copyText: [summary, detail, fullDetail] .filter((value, index, values): value is string => { return Boolean(value) && values.indexOf(value) === index; diff --git a/apps/mobile/src/lib/threadFeedLayout.test.ts b/apps/mobile/src/lib/threadFeedLayout.test.ts deleted file mode 100644 index 73f113eac38..00000000000 --- a/apps/mobile/src/lib/threadFeedLayout.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - isThreadFeedNearEnd, - resolveThreadFeedBottomInset, - threadFeedDistanceFromEnd, -} from "./threadFeedLayout"; - -describe("thread feed layout", () => { - it("accounts for the bottom inset when measuring distance from the end", () => { - const metrics = { - contentHeight: 900, - viewportHeight: 600, - offsetY: 380, - bottomInset: 100, - }; - - expect(threadFeedDistanceFromEnd(metrics)).toBe(20); - expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); - expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); - }); - - it("does not double count chrome already included in the measured composer overlay", () => { - expect( - resolveThreadFeedBottomInset({ - estimatedOverlayHeight: 162, - measuredOverlayHeight: 182, - gap: 8, - }), - ).toBe(190); - }); -}); diff --git a/apps/mobile/src/lib/threadFeedLayout.ts b/apps/mobile/src/lib/threadFeedLayout.ts deleted file mode 100644 index de7946f866d..00000000000 --- a/apps/mobile/src/lib/threadFeedLayout.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface ThreadFeedScrollMetrics { - readonly contentHeight: number; - readonly viewportHeight: number; - readonly offsetY: number; - readonly bottomInset: number; -} - -export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { - return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; -} - -export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { - return threadFeedDistanceFromEnd(metrics) <= threshold; -} - -export function resolveThreadFeedBottomInset(input: { - readonly estimatedOverlayHeight: number; - readonly measuredOverlayHeight: number; - readonly gap: number; -}): number { - return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; -} diff --git a/apps/mobile/src/lib/typography.test.ts b/apps/mobile/src/lib/typography.test.ts new file mode 100644 index 00000000000..6a021dabcce --- /dev/null +++ b/apps/mobile/src/lib/typography.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; + +describe("mobile typography", () => { + it("uses the intentional compact mobile font scale", () => { + expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ + 10, 11, 12, 13, 14, 15, 17, 20, 24, 28, + ]); + }); + + it("uses a compact shared style for editable composer text", () => { + expect(MOBILE_TYPOGRAPHY.composer).toEqual({ fontSize: 14, lineHeight: 20 }); + }); + + it("uses caption-sized code with a compact readable row height", () => { + expect(MOBILE_CODE_SURFACE).toMatchObject({ + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, + lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, + rowHeight: 20, + }); + }); +}); diff --git a/apps/mobile/src/lib/typography.ts b/apps/mobile/src/lib/typography.ts new file mode 100644 index 00000000000..644fee36589 --- /dev/null +++ b/apps/mobile/src/lib/typography.ts @@ -0,0 +1,22 @@ +export const MOBILE_TYPOGRAPHY = { + micro: { fontSize: 10, lineHeight: 13 }, + caption: { fontSize: 11, lineHeight: 15 }, + label: { fontSize: 12, lineHeight: 16 }, + footnote: { fontSize: 13, lineHeight: 18 }, + composer: { fontSize: 14, lineHeight: 20 }, + body: { fontSize: 15, lineHeight: 22 }, + headline: { fontSize: 17, lineHeight: 22 }, + title: { fontSize: 20, lineHeight: 26 }, + largeTitle: { fontSize: 24, lineHeight: 30 }, + display: { fontSize: 28, lineHeight: 34 }, +} as const; + +/** Shared geometry for dense, horizontally scrolling code surfaces. */ +export const MOBILE_CODE_SURFACE = { + rowHeight: 20, + gutterWidth: 46, + codePadding: 7, + textVerticalInset: 2, + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, + lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, +} as const; diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 6778b0455d5..7dd92ff067f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -6,6 +6,7 @@ import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useThemeColor } from "../lib/useThemeColor"; import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; @@ -150,9 +151,15 @@ export function ComposerEditor({ ? resolvedTextStyle.fontFamily : "DMSans_400Regular" } - fontSize={typeof resolvedTextStyle.fontSize === "number" ? resolvedTextStyle.fontSize : 15} + fontSize={ + typeof resolvedTextStyle.fontSize === "number" + ? resolvedTextStyle.fontSize + : MOBILE_TYPOGRAPHY.composer.fontSize + } lineHeight={ - typeof resolvedTextStyle.lineHeight === "number" ? resolvedTextStyle.lineHeight : 22 + typeof resolvedTextStyle.lineHeight === "number" + ? resolvedTextStyle.lineHeight + : MOBILE_TYPOGRAPHY.composer.lineHeight } contentInsetVertical={contentInsetVertical} editable={props.editable ?? true} diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 0f20e9e042d..dc2dfdfee03 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -2,6 +2,7 @@ import { TextInputWrapper } from "expo-paste-input"; import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; +import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useThemeColor } from "../lib/useThemeColor"; import { useNativePaste } from "../lib/useNativePaste"; import type { ComposerEditorProps } from "./T3ComposerEditor.types"; @@ -47,8 +48,7 @@ export function ComposerEditor({ minHeight: 0, color: foregroundColor, fontFamily: "DMSans_400Regular", - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.composer, paddingVertical: contentInsetVertical, }, textStyle, diff --git a/apps/mobile/src/native/nativeViewResolutionError.ts b/apps/mobile/src/native/nativeViewResolutionError.ts new file mode 100644 index 00000000000..bfcf8351a66 --- /dev/null +++ b/apps/mobile/src/native/nativeViewResolutionError.ts @@ -0,0 +1,13 @@ +import * as Schema from "effect/Schema"; + +export class NativeViewResolutionError extends Schema.TaggedErrorClass()( + "NativeViewResolutionError", + { + nativeModuleName: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve native view ${this.nativeModuleName}.`; + } +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts new file mode 100644 index 00000000000..b8b827585ea --- /dev/null +++ b/apps/mobile/src/state/assets.ts @@ -0,0 +1,29 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { usePreparedConnection } from "./session"; + +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); + +const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("mobile-asset-url:empty"), +); + +export function useAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): string | null { + const preparedConnection = usePreparedConnection(environmentId); + const result = useAtomValue( + environmentId === null || resource === null + ? EMPTY_ASSET_URL_ATOM + : assetEnvironment.createUrl({ environmentId, input: { resource } }), + ); + if (preparedConnection._tag === "None" || result._tag !== "Success") { + return null; + } + return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); +} diff --git a/apps/mobile/src/state/auth.ts b/apps/mobile/src/state/auth.ts new file mode 100644 index 00000000000..835dee7f783 --- /dev/null +++ b/apps/mobile/src/state/auth.ts @@ -0,0 +1,5 @@ +import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts new file mode 100644 index 00000000000..8199dee3486 --- /dev/null +++ b/apps/mobile/src/state/entities.ts @@ -0,0 +1,58 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { + EnvironmentId, + ScopedProjectRef, + ScopedThreadRef, + ServerConfig, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentProjects } from "./projects"; +import { environmentServerConfigsAtom, serverEnvironment } from "./server"; +import { environmentThreadShells } from "./threads"; + +const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-project:empty"), +); +const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-thread-shell:empty"), +); +const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-server-config:empty"), +); + +export function useProjects(): ReadonlyArray { + return useAtomValue(environmentProjects.projectsAtom); +} + +export function useThreadShells(): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadShellsAtom); +} + +export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { + return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); +} + +export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref), + ); +} + +export function useEnvironmentServerConfig( + environmentId: EnvironmentId | null, +): ServerConfig | null { + return useAtomValue( + environmentId === null + ? EMPTY_SERVER_CONFIG_ATOM + : serverEnvironment.configValueAtom(environmentId), + ); +} + +export function useServerConfigs(): ReadonlyMap { + return useAtomValue(environmentServerConfigsAtom); +} diff --git a/apps/mobile/src/state/environment-session-registry.ts b/apps/mobile/src/state/environment-session-registry.ts deleted file mode 100644 index 3eb94b32c06..00000000000 --- a/apps/mobile/src/state/environment-session-registry.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; - -import type { EnvironmentSession } from "./remote-runtime-types"; - -const environmentSessions = new Map(); -const environmentConnectionListeners = new Set<() => void>(); - -export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { - return environmentSessions.get(environmentId) ?? null; -} - -export function getEnvironmentClient(environmentId: EnvironmentId) { - return getEnvironmentSession(environmentId)?.client ?? null; -} - -export function setEnvironmentSession( - environmentId: EnvironmentId, - session: EnvironmentSession, -): void { - environmentSessions.set(environmentId, session); -} - -export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { - const session = getEnvironmentSession(environmentId); - environmentSessions.delete(environmentId); - return session; -} - -export function drainEnvironmentSessions(): ReadonlyArray { - const sessions = [...environmentSessions.values()]; - environmentSessions.clear(); - return sessions; -} - -export function notifyEnvironmentConnectionListeners() { - for (const listener of environmentConnectionListeners) listener(); -} - -/** - * Subscribe to environment-connection changes (connect / disconnect / reconnect). - * Returns an unsubscribe function. - */ -export function subscribeEnvironmentConnections(listener: () => void): () => void { - environmentConnectionListeners.add(listener); - return () => { - environmentConnectionListeners.delete(listener); - }; -} diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts new file mode 100644 index 00000000000..88d80631ad3 --- /dev/null +++ b/apps/mobile/src/state/environments.ts @@ -0,0 +1,56 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + type EnvironmentPresentation as BaseEnvironmentPresentation, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations } from "./presentation"; +import { useEnvironmentQuery } from "./query"; + +export interface EnvironmentPresentation extends BaseEnvironmentPresentation { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly displayUrl: string | null; + readonly relayManaged: boolean; +} + +export function projectEnvironmentPresentation( + environmentId: EnvironmentId, + presentation: BaseEnvironmentPresentation, +): EnvironmentPresentation { + return { + ...presentation, + environmentId, + label: presentation.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(presentation.entry), + relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + }; +} + +export function useEnvironments() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); + const presentationById = useAtomValue(environmentPresentations.presentationsAtom); + + const environments = useMemo( + () => + [...presentationById.entries()].map(([environmentId, presentation]) => + projectEnvironmentPresentation(environmentId, presentation), + ), + [presentationById], + ); + + return { + isReady: catalog.isReady, + networkStatus, + environments, + presentationById, + }; +} + +export function useEnvironmentConnectionState(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); +} diff --git a/apps/mobile/src/state/filesystem.ts b/apps/mobile/src/state/filesystem.ts new file mode 100644 index 00000000000..19d5b53c4e0 --- /dev/null +++ b/apps/mobile/src/state/filesystem.ts @@ -0,0 +1,5 @@ +import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/git.ts b/apps/mobile/src/state/git.ts new file mode 100644 index 00000000000..66bb3dc0bde --- /dev/null +++ b/apps/mobile/src/state/git.ts @@ -0,0 +1,5 @@ +import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/orchestration.ts b/apps/mobile/src/state/orchestration.ts new file mode 100644 index 00000000000..8c6e1738857 --- /dev/null +++ b/apps/mobile/src/state/orchestration.ts @@ -0,0 +1,5 @@ +import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/presentation.ts b/apps/mobile/src/state/presentation.ts new file mode 100644 index 00000000000..96171d3ea5c --- /dev/null +++ b/apps/mobile/src/state/presentation.ts @@ -0,0 +1,31 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; +import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { serverEnvironment } from "./server"; + +export const environmentPresentations = createEnvironmentPresentationAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + stateAtom: environmentCatalog.stateAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, +}); + +const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-environment-presentation:empty"), +); + +export function useEnvironmentPresentation(environmentId: EnvironmentId | null) { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const presentation = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM + : environmentPresentations.presentationAtom(environmentId), + ); + return { + isReady: catalog.isReady, + presentation, + }; +} diff --git a/apps/mobile/src/state/projects.ts b/apps/mobile/src/state/projects.ts new file mode 100644 index 00000000000..7a879988328 --- /dev/null +++ b/apps/mobile/src/state/projects.ts @@ -0,0 +1,12 @@ +import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; +import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts new file mode 100644 index 00000000000..68c23202308 --- /dev/null +++ b/apps/mobile/src/state/queries.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { buildCheckpointDiffTargets, normalizeComposerPathSearchQuery } from "./queryTargets"; + +describe("appQueries", () => { + it("normalizes composer path search input", () => { + expect(normalizeComposerPathSearchQuery(" src/app ")).toBe("src/app"); + expect(normalizeComposerPathSearchQuery(null)).toBe(""); + }); + + it("routes the first turn range through the full-thread diff query", () => { + const environmentId = EnvironmentId.make("environment-a"); + const threadId = ThreadId.make("thread-a"); + + expect( + buildCheckpointDiffTargets({ + environmentId, + threadId, + fromTurnCount: 0, + toTurnCount: 4, + ignoreWhitespace: true, + }), + ).toEqual({ + fullThread: { + environmentId, + input: { + threadId, + toTurnCount: 4, + ignoreWhitespace: true, + }, + }, + turn: null, + }); + }); + + it("routes later ranges through the incremental turn diff query", () => { + const environmentId = EnvironmentId.make("environment-a"); + const threadId = ThreadId.make("thread-a"); + + expect( + buildCheckpointDiffTargets({ + environmentId, + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: false, + }), + ).toEqual({ + fullThread: null, + turn: { + environmentId, + input: { + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: false, + }, + }, + }); + }); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts new file mode 100644 index 00000000000..ea625995928 --- /dev/null +++ b/apps/mobile/src/state/queries.ts @@ -0,0 +1,134 @@ +import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useEffect, useMemo, useState } from "react"; + +import { orchestrationEnvironment } from "./orchestration"; +import { projectEnvironment } from "./projects"; +import { useEnvironmentQuery } from "./query"; +import { useEnvironmentThread } from "./threads"; +import { vcsEnvironment } from "./vcs"; +import { + buildCheckpointDiffTargets, + normalizeComposerPathSearchQuery, + type CheckpointDiffTarget, +} from "./queryTargets"; + +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; +const COMPOSER_PATH_SEARCH_LIMIT = 20; +const VCS_REF_LIST_LIMIT = 100; + +export interface ThreadDetailView { + readonly data: OrchestrationThread | null; + readonly error: string | null; + readonly isPending: boolean; + readonly isDeleted: boolean; +} + +export interface ComposerPathSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string | null; +} + +function useDebouncedValue(value: A, delayMs: number): A { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebounced(value); + }, delayMs); + return () => { + clearTimeout(timer); + }; + }, [delayMs, value]); + + return debounced; +} + +export function useThreadDetail( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): ThreadDetailView { + const state = useEnvironmentThread(environmentId, threadId); + return { + data: Option.getOrNull(state.data), + error: Option.getOrNull(state.error), + isPending: state.status === "synchronizing", + isDeleted: state.status === "deleted", + }; +} + +export function useBranches(input: { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query?: string | null; +}) { + const query = input.query?.trim() ?? ""; + return useEnvironmentQuery( + input.environmentId !== null && input.cwd !== null + ? vcsEnvironment.listRefs({ + environmentId: input.environmentId, + input: { + cwd: input.cwd, + ...(query.length > 0 ? { query } : {}), + limit: VCS_REF_LIST_LIMIT, + }, + }) + : null, + ); +} + +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + const normalizedTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: normalizeComposerPathSearchQuery(target.query), + }), + [target.cwd, target.environmentId, target.query], + ); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query.length > 0 + ? projectEnvironment.searchEntries({ + environmentId: debouncedTarget.environmentId, + input: { + cwd: debouncedTarget.cwd, + query: debouncedTarget.query, + limit: COMPOSER_PATH_SEARCH_LIMIT, + }, + }) + : null, + ); + + return { + entries: result.data?.entries ?? [], + error: result.error, + isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + refresh: result.refresh, + }; +} + +export function useCheckpointDiff(target: CheckpointDiffTarget) { + const targets = useMemo( + () => buildCheckpointDiffTargets(target), + [ + target.environmentId, + target.fromTurnCount, + target.ignoreWhitespace, + target.threadId, + target.toTurnCount, + ], + ); + const fullThread = useEnvironmentQuery( + targets.fullThread === null + ? null + : orchestrationEnvironment.fullThreadDiff(targets.fullThread), + ); + const turn = useEnvironmentQuery( + targets.turn === null ? null : orchestrationEnvironment.turnDiff(targets.turn), + ); + return targets.fullThread === null ? turn : fullThread; +} diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts new file mode 100644 index 00000000000..c29d01d397b --- /dev/null +++ b/apps/mobile/src/state/query.ts @@ -0,0 +1,36 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("mobile-environment-query:empty"), +); + +export interface EnvironmentQueryView { + readonly data: A | null; + readonly error: string | null; + readonly isPending: boolean; + readonly refresh: () => void; +} + +function formatError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The environment request failed."; +} + +export function useEnvironmentQuery( + atom: Atom.Atom> | null, +): EnvironmentQueryView { + const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; + const result = useAtomValue(selectedAtom); + const refresh = useAtomRefresh(selectedAtom); + return { + data: Option.getOrNull(AsyncResult.value(result)), + error: result._tag === "Failure" ? formatError(result.cause) : null, + isPending: atom !== null && result.waiting, + refresh, + }; +} diff --git a/apps/mobile/src/state/queryTargets.ts b/apps/mobile/src/state/queryTargets.ts new file mode 100644 index 00000000000..a52da3fc134 --- /dev/null +++ b/apps/mobile/src/state/queryTargets.ts @@ -0,0 +1,51 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +export interface CheckpointDiffTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly fromTurnCount: number | null; + readonly toTurnCount: number | null; + readonly ignoreWhitespace: boolean; +} + +export function normalizeComposerPathSearchQuery(query: string | null): string { + return query?.trim() ?? ""; +} + +export function buildCheckpointDiffTargets(target: CheckpointDiffTarget) { + if ( + target.environmentId === null || + target.threadId === null || + target.fromTurnCount === null || + target.toTurnCount === null + ) { + return { fullThread: null, turn: null } as const; + } + + if (target.fromTurnCount === 0) { + return { + fullThread: { + environmentId: target.environmentId, + input: { + threadId: target.threadId, + toTurnCount: target.toTurnCount, + ignoreWhitespace: target.ignoreWhitespace, + }, + }, + turn: null, + } as const; + } + + return { + fullThread: null, + turn: { + environmentId: target.environmentId, + input: { + threadId: target.threadId, + fromTurnCount: target.fromTurnCount, + toTurnCount: target.toTurnCount, + ignoreWhitespace: target.ignoreWhitespace, + }, + }, + } as const; +} diff --git a/apps/mobile/src/state/relay.ts b/apps/mobile/src/state/relay.ts new file mode 100644 index 00000000000..f078572736b --- /dev/null +++ b/apps/mobile/src/state/relay.ts @@ -0,0 +1,6 @@ +import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const relayEnvironmentDiscovery = + createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts index 054203715bd..89abd3c222e 100644 --- a/apps/mobile/src/state/remote-runtime-types.ts +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -1,27 +1,24 @@ -import type { - EnvironmentConnection, - EnvironmentConnectionState, - WsRpcClient, -} from "@t3tools/client-runtime"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { EnvironmentId, ThreadId, type ServerConfig } from "@t3tools/contracts"; -export type { EnvironmentRuntimeState } from "@t3tools/client-runtime"; +export interface EnvironmentRuntimeState { + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly serverConfig: ServerConfig | null; +} export interface ConnectedEnvironmentSummary { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly displayUrl: string; readonly isRelayManaged: boolean; - readonly connectionState: EnvironmentConnectionState; + readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; } export interface SelectedThreadRef { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; } - -export interface EnvironmentSession { - readonly client: WsRpcClient; - readonly connection: EnvironmentConnection; -} diff --git a/apps/mobile/src/state/review.ts b/apps/mobile/src/state/review.ts new file mode 100644 index 00000000000..e4289d1f1d5 --- /dev/null +++ b/apps/mobile/src/state/review.ts @@ -0,0 +1,5 @@ +import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts new file mode 100644 index 00000000000..1b7060571a5 --- /dev/null +++ b/apps/mobile/src/state/server.ts @@ -0,0 +1,14 @@ +import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; +import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSession } from "./session"; + +export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { + initialConfigValueAtom: environmentSession.initialConfigValueAtom, +}); +export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, +}); diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts new file mode 100644 index 00000000000..747ab7c72ee --- /dev/null +++ b/apps/mobile/src/state/session.ts @@ -0,0 +1,21 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); + +const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( + Atom.withLabel("mobile-prepared-connection:empty"), +); + +export function usePreparedConnection(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentId === null + ? EMPTY_PREPARED_CONNECTION_ATOM + : environmentSession.preparedConnectionValueAtom(environmentId), + ); +} diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts new file mode 100644 index 00000000000..e879dd25e29 --- /dev/null +++ b/apps/mobile/src/state/shell.ts @@ -0,0 +1,17 @@ +import { + createEnvironmentShellAtoms, + createEnvironmentShellSummaryAtom, + createEnvironmentSnapshotAtom, + createShellEnvironmentAtoms, +} from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); +export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); +export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); +export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, +}); diff --git a/apps/mobile/src/state/sourceControl.ts b/apps/mobile/src/state/sourceControl.ts new file mode 100644 index 00000000000..aa6255f85ff --- /dev/null +++ b/apps/mobile/src/state/sourceControl.ts @@ -0,0 +1,5 @@ +import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/terminal.ts b/apps/mobile/src/state/terminal.ts new file mode 100644 index 00000000000..920267c33d5 --- /dev/null +++ b/apps/mobile/src/state/terminal.ts @@ -0,0 +1,5 @@ +import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts new file mode 100644 index 00000000000..7762e6cdf78 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -0,0 +1,177 @@ +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; + +import { + flattenQueuedThreadMessages, + groupQueuedThreadMessages, + type QueuedThreadMessage, +} from "./thread-outbox-model"; +import type { ThreadOutboxStorage } from "./thread-outbox-storage"; + +export class ThreadOutboxManagerError extends Schema.TaggedErrorClass()( + "ThreadOutboxManagerError", + { + operation: Schema.Literals([ + "load", + "enqueue", + "remove", + "clear-environment-load", + "clear-environment-remove", + ]), + environmentId: Schema.NullOr(EnvironmentId), + threadId: Schema.NullOr(ThreadId), + messageId: Schema.NullOr(MessageId), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Thread outbox operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}.`; + } +} + +export interface ThreadOutboxManagerOptions { + readonly registry: AtomRegistry.AtomRegistry; + readonly storage: ThreadOutboxStorage; + readonly warn?: (message: string, error: unknown) => void; +} + +export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { + const queuedMessagesByThreadKeyAtom = Atom.make< + Record> + >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:queued-messages")); + const warn = + options.warn ?? + ((message: string, error: unknown) => { + console.warn(message, error); + }); + let loadPromise: Promise | null = null; + let mutationQueue: Promise = Promise.resolve(); + + const serialize = (mutation: () => Promise): Promise => { + const result = mutationQueue.then(mutation, mutation); + mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + + const currentMessages = (): ReadonlyArray => + flattenQueuedThreadMessages(options.registry.get(queuedMessagesByThreadKeyAtom)); + + const setMessages = (messages: ReadonlyArray): void => { + options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages)); + }; + + const load = (): Promise => { + if (loadPromise !== null) { + return loadPromise; + } + loadPromise = serialize(async () => { + const persistedMessages = await options.storage.load(); + setMessages([...persistedMessages, ...currentMessages()]); + }).catch((cause) => { + loadPromise = null; + warn( + "[thread-outbox] failed to load persisted messages", + new ThreadOutboxManagerError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + cause, + }), + ); + }); + return loadPromise; + }; + + const enqueue = (message: QueuedThreadMessage): Promise => + serialize(async () => { + try { + await options.storage.write(message); + } catch (cause) { + throw new ThreadOutboxManagerError({ + operation: "enqueue", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }); + } + setMessages([...currentMessages(), message]); + }); + + const remove = (message: QueuedThreadMessage): Promise => + serialize(async () => { + try { + await options.storage.remove(message); + } catch (cause) { + throw new ThreadOutboxManagerError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }); + } + setMessages( + currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + ); + }); + + const clearEnvironment = (environmentId: EnvironmentId): Promise => + serialize(async () => { + const persisted = await options.storage.load().catch((cause) => { + warn( + "[thread-outbox] failed to load messages while clearing environment", + new ThreadOutboxManagerError({ + operation: "clear-environment-load", + environmentId, + threadId: null, + messageId: null, + cause, + }), + ); + return []; + }); + const allMessages = flattenQueuedThreadMessages( + groupQueuedThreadMessages([...persisted, ...currentMessages()]), + ); + const removedMessageIds = new Set(); + + await Promise.all( + allMessages + .filter((message) => message.environmentId === environmentId) + .map(async (message) => { + try { + await options.storage.remove(message); + removedMessageIds.add(message.messageId); + } catch (cause) { + warn( + "[thread-outbox] failed to clear persisted message", + new ThreadOutboxManagerError({ + operation: "clear-environment-remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }), + ); + } + }), + ); + + setMessages(allMessages.filter((message) => !removedMessageIds.has(message.messageId))); + }); + + return { + queuedMessagesByThreadKeyAtom, + serialize, + load, + enqueue, + remove, + clearEnvironment, + }; +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts new file mode 100644 index 00000000000..ed0c06ba38b --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -0,0 +1,173 @@ +import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import { + CommandId, + EnvironmentId, + IsoDateTime, + MessageId, + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + type ModelSelection as ModelSelectionType, + type ProviderInteractionMode as ProviderInteractionModeType, + type RuntimeMode as RuntimeModeType, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; +import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { scopedThreadKey } from "../lib/scopedEntities"; + +const THREAD_OUTBOX_SCHEMA_VERSION = 2; +const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; + +export const QueuedThreadMessageSchema = Schema.Struct({ + schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]), + environmentId: EnvironmentId, + threadId: ThreadId, + messageId: MessageId, + commandId: CommandId, + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelection), + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), + createdAt: IsoDateTime, +}); + +const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema); +const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema); + +export interface QueuedThreadMessage { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly commandId: CommandId; + readonly text: string; + readonly attachments: ReadonlyArray; + readonly modelSelection?: ModelSelectionType; + readonly runtimeMode?: RuntimeModeType; + readonly interactionMode?: ProviderInteractionModeType; + readonly createdAt: string; +} + +export interface ThreadSettingsSnapshot { + readonly modelSelection: ModelSelectionType; + readonly runtimeMode: RuntimeModeType; + readonly interactionMode: ProviderInteractionModeType; +} + +export function resolveQueuedThreadSettings( + message: QueuedThreadMessage, + thread: ThreadSettingsSnapshot, +): ThreadSettingsSnapshot { + return { + modelSelection: message.modelSelection ?? thread.modelSelection, + runtimeMode: message.runtimeMode ?? thread.runtimeMode, + interactionMode: message.interactionMode ?? thread.interactionMode, + }; +} + +export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean { + return ( + left.instanceId === right.instanceId && + left.model === right.model && + JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) + ); +} + +export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { + return encodeStoredQueuedThreadMessage({ + schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, + ...message, + }); +} + +export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage { + const { schemaVersion: _, ...message } = decodeStoredQueuedThreadMessage(value); + return message; +} + +export function groupQueuedThreadMessages( + messages: ReadonlyArray, +): Record> { + const deduplicated = new Map(); + for (const message of messages) { + deduplicated.set(message.messageId, message); + } + + const grouped: Record> = {}; + for (const message of deduplicated.values()) { + const threadKey = scopedThreadKey(message.environmentId, message.threadId); + (grouped[threadKey] ??= []).push(message); + } + for (const queue of Object.values(grouped)) { + queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + return grouped; +} + +export function flattenQueuedThreadMessages( + queues: Record>, +): ReadonlyArray { + return Object.values(queues).flat(); +} + +export function threadOutboxRetryDelayMs(attempt: number): number { + return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS); +} + +export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; + +export function resolveThreadOutboxDeliveryAction(input: { + readonly threadExists: boolean; + readonly shellStatus: EnvironmentShellStatus; + readonly environmentConnected: boolean; + readonly threadBusy: boolean; +}): ThreadOutboxDeliveryAction { + if (!input.threadExists) { + return input.shellStatus === "live" ? "remove" : "wait"; + } + return input.environmentConnected && !input.threadBusy ? "send" : "wait"; +} + +function errorMessage(error: unknown): string | null { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "object" && error !== null && "message" in error) { + return typeof error.message === "string" ? error.message : null; + } + return typeof error === "string" ? error : null; +} + +export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { + if ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "ConnectionTransientError" + ) { + return true; + } + return isTransportConnectionErrorMessage(errorMessage(error)); +} + +export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; +export type ThreadOutboxFailureAction = "retry" | "discard"; + +export function resolveThreadOutboxFailureAction(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; +}): ThreadOutboxFailureAction { + if ( + input.stage === "settings-sync" || + input.interrupted || + shouldRetryThreadOutboxDelivery(input.error) + ) { + return "retry"; + } + return "discard"; +} diff --git a/apps/mobile/src/state/thread-outbox-storage.ts b/apps/mobile/src/state/thread-outbox-storage.ts new file mode 100644 index 00000000000..2003c220bad --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-storage.ts @@ -0,0 +1,126 @@ +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { + decodeQueuedThreadMessage, + encodeQueuedThreadMessage, + type QueuedThreadMessage, +} from "./thread-outbox-model"; + +const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; + +export class ThreadOutboxStorageError extends Schema.TaggedErrorClass()( + "ThreadOutboxStorageError", + { + operation: Schema.Literals(["load", "read-message", "write", "remove"]), + environmentId: Schema.NullOr(EnvironmentId), + threadId: Schema.NullOr(ThreadId), + messageId: Schema.NullOr(MessageId), + fileName: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Thread outbox storage operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}, file ${this.fileName ?? "unknown"}.`; + } +} + +export interface ThreadOutboxStorage { + readonly load: () => Promise>; + readonly write: (message: QueuedThreadMessage) => Promise; + readonly remove: (message: QueuedThreadMessage) => Promise; +} + +function messageFileName(messageId: MessageId): string { + return `${encodeURIComponent(messageId)}.json`; +} + +async function getOutboxDirectory() { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, THREAD_OUTBOX_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + return directory; +} + +async function getMessageFile(messageId: MessageId) { + const { File } = await import("expo-file-system"); + return new File(await getOutboxDirectory(), messageFileName(messageId)); +} + +export const expoThreadOutboxStorage: ThreadOutboxStorage = { + load: async () => { + const messages: QueuedThreadMessage[] = []; + try { + const { File } = await import("expo-file-system"); + const directory = await getOutboxDirectory(); + + for (const entry of directory.list()) { + if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + continue; + } + try { + messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); + } catch (cause) { + console.warn( + "[thread-outbox] ignored invalid persisted message", + new ThreadOutboxStorageError({ + operation: "read-message", + environmentId: null, + threadId: null, + messageId: null, + fileName: entry.name, + cause, + }), + ); + } + } + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + fileName: null, + cause, + }); + } + return messages; + }, + write: async (message) => { + const fileName = messageFileName(message.messageId); + try { + const file = await getMessageFile(message.messageId); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encodeQueuedThreadMessage(message))); + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "write", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + fileName, + cause, + }); + } + }, + remove: async (message) => { + const fileName = messageFileName(message.messageId); + try { + const file = await getMessageFile(message.messageId); + if (file.exists) { + file.delete(); + } + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + fileName, + cause, + }); + } + }, +}; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts new file mode 100644 index 00000000000..68d06d2e424 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { AtomRegistry } from "effect/unstable/reactivity"; + +import { + decodeQueuedThreadMessage, + encodeQueuedThreadMessage, + groupQueuedThreadMessages, + modelSelectionsEqual, + resolveThreadOutboxDeliveryAction, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, + shouldRetryThreadOutboxDelivery, + threadOutboxRetryDelayMs, + type QueuedThreadMessage, +} from "./thread-outbox-model"; +import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; +import type { ThreadOutboxStorage } from "./thread-outbox-storage"; + +function queuedMessage(input: { + readonly environmentId?: string; + readonly threadId?: string; + readonly messageId: string; + readonly createdAt: string; +}): QueuedThreadMessage { + return { + environmentId: EnvironmentId.make(input.environmentId ?? "environment-1"), + threadId: ThreadId.make(input.threadId ?? "thread-1"), + messageId: MessageId.make(input.messageId), + commandId: CommandId.make(`command-${input.messageId}`), + text: input.messageId, + attachments: [], + createdAt: input.createdAt, + }; +} + +describe("thread outbox", () => { + it("groups messages by scoped thread and preserves creation order", () => { + const later = queuedMessage({ + messageId: "message-2", + createdAt: "2026-06-08T10:00:02.000Z", + }); + const earlier = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + expect(groupQueuedThreadMessages([later, earlier])).toEqual({ + "environment-1:thread-1": [earlier, later], + }); + }); + + it("decodes the persisted schema and rejects incomplete messages", () => { + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + expect( + decodeQueuedThreadMessage({ + schemaVersion: 1, + ...message, + }), + ).toEqual(message); + expect(() => + decodeQueuedThreadMessage({ + schemaVersion: 1, + environmentId: "environment-1", + }), + ).toThrow(); + }); + + it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { + const legacyMessage = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const selectedMessage = { + ...legacyMessage, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } satisfies QueuedThreadMessage; + + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( + selectedMessage, + ); + expect( + resolveQueuedThreadSettings(legacyMessage, { + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }), + ).toEqual({ + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }); + }); + + it("compares model options as part of the queued settings change", () => { + const base = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + } as const; + + expect(modelSelectionsEqual(base, base)).toBe(true); + expect( + modelSelectionsEqual(base, { + ...base, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }), + ).toBe(false); + }); + + it("backs off queued delivery retries and caps them at sixteen seconds", () => { + expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ + 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, + ]); + }); + + it("serializes mutations even when an earlier mutation is slower", async () => { + const registry = AtomRegistry.make(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async () => undefined, + remove: async () => undefined, + }, + }); + const order: string[] = []; + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = manager.serialize(async () => { + order.push("first:start"); + await firstBlocked; + order.push("first:end"); + }); + const second = manager.serialize(async () => { + order.push("second"); + }); + + await Promise.resolve(); + expect(order).toEqual(["first:start"]); + releaseFirst(); + await Promise.all([first, second]); + expect(order).toEqual(["first:start", "first:end", "second"]); + registry.dispose(); + }); + + it("holds the mutation queue while persisted messages are loading", async () => { + const registry = AtomRegistry.make(); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const stored = new Map([[message.messageId, message]]); + let loadCalls = 0; + let removeCalls = 0; + let releaseInitialLoad!: () => void; + const initialLoadBlocked = new Promise((resolve) => { + releaseInitialLoad = resolve; + }); + const storage: ThreadOutboxStorage = { + load: async () => { + loadCalls += 1; + if (loadCalls === 1) { + await initialLoadBlocked; + } + return [...stored.values()]; + }, + write: async () => undefined, + remove: async (candidate) => { + removeCalls += 1; + stored.delete(candidate.messageId); + }, + }; + const manager = createThreadOutboxManager({ registry, storage }); + + const loading = manager.load(); + await Promise.resolve(); + const clearing = manager.clearEnvironment(message.environmentId); + await Promise.resolve(); + await Promise.resolve(); + + expect(loadCalls).toBe(1); + expect(removeCalls).toBe(0); + + releaseInitialLoad(); + await Promise.all([loading, clearing]); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + registry.dispose(); + }); + + it("reports structured load failures and permits a retry", async () => { + const registry = AtomRegistry.make(); + const loadCause = new Error("storage unavailable"); + const warnings: Array<{ message: string; error: unknown }> = []; + let loadCalls = 0; + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => { + loadCalls += 1; + if (loadCalls === 1) throw loadCause; + return []; + }, + write: async () => undefined, + remove: async () => undefined, + }, + warn: (message, error) => warnings.push({ message, error }), + }); + + await manager.load(); + expect(warnings).toEqual([ + { + message: "[thread-outbox] failed to load persisted messages", + error: new ThreadOutboxManagerError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + cause: loadCause, + }), + }, + ]); + + await manager.load(); + expect(loadCalls).toBe(2); + registry.dispose(); + }); + + it("keeps atom state aligned with durable writes and removals", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + const removalCause = new Error("remove failed"); + let failRemoval = true; + const storage: ThreadOutboxStorage = { + load: async () => [...stored.values()], + write: async (message) => { + stored.set(message.messageId, message); + }, + remove: async (message) => { + if (failRemoval) { + throw removalCause; + } + stored.delete(message.messageId); + }, + }; + const manager = createThreadOutboxManager({ registry, storage }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + await manager.enqueue(message); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [message], + }); + + await expect(manager.remove(message)).rejects.toEqual( + new ThreadOutboxManagerError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause: removalCause, + }), + ); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [message], + }); + + failRemoval = false; + await manager.remove(message); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + registry.dispose(); + }); + + it("only removes a missing-thread message after shell synchronization is live", () => { + expect( + resolveThreadOutboxDeliveryAction({ + threadExists: false, + shellStatus: "synchronizing", + environmentConnected: true, + threadBusy: false, + }), + ).toBe("wait"); + expect( + resolveThreadOutboxDeliveryAction({ + threadExists: false, + shellStatus: "live", + environmentConnected: true, + threadBusy: false, + }), + ).toBe("remove"); + expect( + resolveThreadOutboxDeliveryAction({ + threadExists: true, + shellStatus: "live", + environmentConnected: true, + threadBusy: false, + }), + ).toBe("send"); + }); + + it("retries transport failures but drops deterministic command failures", () => { + expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true); + expect( + shouldRetryThreadOutboxDelivery({ + _tag: "ConnectionTransientError", + message: "temporarily unavailable", + }), + ).toBe(true); + expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); + }); + + it("retains queued messages when settings synchronization fails before startTurn", () => { + const deterministicFailure = new Error("Thread no longer exists"); + + expect( + resolveThreadOutboxFailureAction({ + stage: "settings-sync", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("retry"); + expect( + resolveThreadOutboxFailureAction({ + stage: "start-turn", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("discard"); + }); +}); diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts new file mode 100644 index 00000000000..d5eb383a0e9 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox.ts @@ -0,0 +1,29 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import { appAtomRegistry } from "./atom-registry"; +import { createThreadOutboxManager } from "./thread-outbox-manager"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { expoThreadOutboxStorage } from "./thread-outbox-storage"; + +export * from "./thread-outbox-model"; + +export const threadOutboxManager = createThreadOutboxManager({ + registry: appAtomRegistry, + storage: expoThreadOutboxStorage, +}); + +export function ensureThreadOutboxLoaded(): void { + void threadOutboxManager.load(); +} + +export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise { + return threadOutboxManager.enqueue(message); +} + +export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise { + return threadOutboxManager.remove(message); +} + +export function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise { + return threadOutboxManager.clearEnvironment(environmentId); +} diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts new file mode 100644 index 00000000000..7f247123051 --- /dev/null +++ b/apps/mobile/src/state/threads.ts @@ -0,0 +1,45 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createEnvironmentThreadDetailAtoms, + createEnvironmentThreadShellAtoms, + createEnvironmentThreadStateAtoms, + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, + createThreadEnvironmentAtoms, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); +export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( + environmentThreads.stateAtom, +); +export const environmentThreadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); + +const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( + Atom.withLabel("mobile-environment-thread:empty"), +); + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + const result = useAtomValue( + environmentId !== null && threadId !== null + ? environmentThreads.stateAtom(environmentId, threadId) + : EMPTY_THREAD_STATE_ATOM, + ); + return Option.getOrElse( + AsyncResult.value(result), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ) as EnvironmentThreadState; +} diff --git a/apps/mobile/src/state/use-atom-command.ts b/apps/mobile/src/state/use-atom-command.ts new file mode 100644 index 00000000000..37ce280e9f4 --- /dev/null +++ b/apps/mobile/src/state/use-atom-command.ts @@ -0,0 +1,23 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + type AtomCommand, + type AtomCommandOptions, + type AtomCommandResult, + runAtomCommand, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useContext } from "react"; + +export function useAtomCommand( + command: AtomCommand, + options?: string | AtomCommandOptions, +): (value: W) => Promise> { + const registry = useContext(RegistryContext); + const label = typeof options === "string" ? options : (options?.label ?? command.label); + const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); + const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + + return useCallback( + (value: W) => runAtomCommand(registry, command, value, { label, reportFailure, reportDefect }), + [command, label, registry, reportDefect, reportFailure], + ); +} diff --git a/apps/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts new file mode 100644 index 00000000000..22f971e09a5 --- /dev/null +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -0,0 +1,30 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + executeAtomQuery, + type AtomCommandOptions, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult, type Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext } from "react"; + +export function useAtomQueryRunner( + family: (target: T) => Atom.Atom>, + options?: string | AtomCommandOptions, +): (target: T) => Promise> { + const registry = useContext(RegistryContext); + const explicitLabel = typeof options === "string" ? options : options?.label; + const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); + const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + + return useCallback( + (target: T) => { + const atom = family(target); + return executeAtomQuery(registry, atom, { + label: explicitLabel ?? atom.label?.[0] ?? "atom query", + reportFailure, + reportDefect, + }); + }, + [explicitLabel, family, registry, reportDefect, reportFailure], + ); +} diff --git a/apps/mobile/src/state/use-checkpoint-diff.ts b/apps/mobile/src/state/use-checkpoint-diff.ts deleted file mode 100644 index 3111008f00a..00000000000 --- a/apps/mobile/src/state/use-checkpoint-diff.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime"; - -import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; - -export const checkpointDiffManager = createCheckpointDiffManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null, -}); - -export function loadCheckpointDiff( - target: CheckpointDiffTarget, - options?: { readonly force?: boolean }, -) { - return checkpointDiffManager.load(target, undefined, options); -} diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts new file mode 100644 index 00000000000..d02abb6a265 --- /dev/null +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; + +import { appAtomRegistry } from "./atom-registry"; +import { + clearComposerDraftContentState, + composerDraftsAtom, + decodePersistedComposerDrafts, + type ComposerDraft, + getComposerDraftSnapshot, + removeComposerDraftsForEnvironment, +} from "./use-composer-drafts"; + +const DRAFT: ComposerDraft = { + text: "hello", + attachments: [], +}; + +afterEach(() => { + appAtomRegistry.set(composerDraftsAtom, {}); +}); + +describe("mobile composer drafts", () => { + it("hydrates selector state even when the message content is empty", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }, + }), + ).toEqual({ + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }); + }); + + it("keeps legacy content-only drafts and rejects invalid selector state", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + }), + ).toEqual({ + "environment-1:thread-1": DRAFT, + }); + + expect(() => + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + ...DRAFT, + runtimeMode: "sometimes-safe", + }, + }, + }), + ).toThrow(); + }); + + it("clears sent content without clearing the selected model or workspace", () => { + const draftKey = "environment-1:thread-1"; + const draft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }; + + expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ + [draftKey]: { + ...draft, + text: "", + attachments: [], + }, + }); + }); + + it("reads the latest selector state synchronously for send", () => { + const draftKey = "environment-1:thread-1"; + const selectedDraft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }; + appAtomRegistry.set(composerDraftsAtom, { [draftKey]: selectedDraft }); + + expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); + }); + + it("removes only drafts owned by the selected environment", () => { + const environmentId = EnvironmentId.make("environment-cloud"); + const retainedEnvironmentId = EnvironmentId.make("environment-local"); + + expect( + removeComposerDraftsForEnvironment( + { + [`${environmentId}:thread-cloud`]: DRAFT, + [`new-task:${environmentId}:project-cloud`]: DRAFT, + [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, + }, + environmentId, + ), + ).toEqual({ + [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, + }); + }); +}); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 6ac9786ad0e..9e2c1566190 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,7 +1,18 @@ import { useAtomValue } from "@effect/atom-react"; +import { + ModelSelection as ModelSelectionSchema, + ProviderInteractionMode as ProviderInteractionModeSchema, + RuntimeMode as RuntimeModeSchema, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; @@ -10,16 +21,64 @@ const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; const COMPOSER_DRAFTS_FILE = "drafts.json"; const PERSIST_DEBOUNCE_MS = 200; +export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass()( + "ComposerDraftPersistenceError", + { + operation: Schema.Literals(["open", "read", "decode", "encode", "write", "hydrate"]), + directory: Schema.String, + fileName: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Composer draft persistence operation ${this.operation} failed for ${this.directory}/${this.fileName}.`; + } +} + export interface ComposerDraft { readonly text: string; readonly attachments: ReadonlyArray; + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } -interface PersistedComposerDrafts { - readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION; - readonly drafts: Record; +export interface ComposerDraftWorkspaceSelection { + readonly mode: "local" | "worktree"; + readonly branch: string | null; + readonly worktreePath: string | null; } +export type ComposerDraftSettingsUpdate = Pick< + ComposerDraft, + "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" +>; + +const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ + mode: Schema.Literals(["local", "worktree"]), + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), +}); + +const ComposerDraftSchema = Schema.Struct({ + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelectionSchema), + runtimeMode: Schema.optional(RuntimeModeSchema), + interactionMode: Schema.optional(ProviderInteractionModeSchema), + workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), +}); + +const PersistedComposerDraftsSchema = Schema.Struct({ + schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), + drafts: Schema.Record(Schema.String, ComposerDraftSchema), +}); + +const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( + PersistedComposerDraftsSchema, +); + const EMPTY_DRAFT: ComposerDraft = { text: "", attachments: [], @@ -30,7 +89,7 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); -let loadStarted = false; +let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { @@ -38,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { return EMPTY_DRAFT; } return { + ...draft, text: draft.text, attachments: draft.attachments, }; } +export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { + return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); +} + function isEmptyDraft(draft: ComposerDraft): boolean { - return draft.text.length === 0 && draft.attachments.length === 0; + return ( + draft.text.length === 0 && + draft.attachments.length === 0 && + draft.modelSelection === undefined && + draft.runtimeMode === undefined && + draft.interactionMode === undefined && + draft.workspaceSelection === undefined + ); +} + +export function decodePersistedComposerDrafts(value: unknown): Record { + const parsed = decodePersistedComposerDraftsDocument(value); + return Object.fromEntries( + Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), + ); } async function getComposerDraftsFile() { @@ -55,45 +133,63 @@ async function getComposerDraftsFile() { } async function loadPersistedComposerDrafts(): Promise> { + let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { return {}; } - const parsed = JSON.parse(await file.text()) as Partial; - if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { - return {}; - } - return Object.fromEntries( - Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => { - const draft = entry[1]; - return ( - typeof draft?.text === "string" && - Array.isArray(draft.attachments) && - !isEmptyDraft(draft) - ); + operation = "read"; + const raw = await file.text(); + operation = "decode"; + return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); + } catch (cause) { + console.warn( + "[composer-drafts] ignored persisted draft failure", + new ComposerDraftPersistenceError({ + operation, + directory: COMPOSER_DRAFTS_DIRECTORY, + fileName: COMPOSER_DRAFTS_FILE, + cause, }), ); - } catch { return {}; } } -async function savePersistedComposerDrafts(drafts: Record): Promise { +async function writePersistedComposerDrafts(drafts: Record): Promise { + let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); + operation = "encode"; const nonEmptyDrafts = Object.fromEntries( Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), ); - const document: PersistedComposerDrafts = { + const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, - }; + } as const; + const encoded = JSON.stringify(document); + operation = "write"; if (!file.exists) { file.create({ intermediates: true, overwrite: true }); } - file.write(JSON.stringify(document)); - } catch { + file.write(encoded); + } catch (cause) { + throw new ComposerDraftPersistenceError({ + operation, + directory: COMPOSER_DRAFTS_DIRECTORY, + fileName: COMPOSER_DRAFTS_FILE, + cause, + }); + } +} + +async function savePersistedComposerDrafts(drafts: Record): Promise { + try { + await writePersistedComposerDrafts(drafts); + } catch (error) { + console.warn("[composer-drafts] failed to persist drafts", error); // Draft persistence is best-effort; in-memory drafts still keep working. } } @@ -109,20 +205,32 @@ function schedulePersistComposerDrafts(drafts: Record): v } export function ensureComposerDraftsLoaded(): void { - if (loadStarted) { + if (loadPromise !== null) { return; } - loadStarted = true; - void loadPersistedComposerDrafts().then((persistedDrafts) => { - if (Object.keys(persistedDrafts).length === 0) { - return; - } - const current = appAtomRegistry.get(composerDraftsAtom); - appAtomRegistry.set(composerDraftsAtom, { - ...persistedDrafts, - ...current, + loadPromise = loadPersistedComposerDrafts() + .then((persistedDrafts) => { + if (Object.keys(persistedDrafts).length === 0) { + return; + } + const current = appAtomRegistry.get(composerDraftsAtom); + appAtomRegistry.set(composerDraftsAtom, { + ...persistedDrafts, + ...current, + }); + }) + .catch((cause) => { + console.warn( + "[composer-drafts] failed to hydrate drafts", + new ComposerDraftPersistenceError({ + operation: "hydrate", + directory: COMPOSER_DRAFTS_DIRECTORY, + fileName: COMPOSER_DRAFTS_FILE, + cause, + }), + ); + // Draft loading is best-effort; in-memory drafts still keep working. }); - }); } function updateComposerDrafts( @@ -223,6 +331,55 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) }); } +export function updateComposerDraftSettings( + draftKey: string, + settings: Partial, +): void { + updateComposerDrafts((current) => { + const draft = { + ...normalizeDraft(current[draftKey]), + ...settings, + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; + }); +} + +export function clearComposerDraftContentState( + current: Record, + draftKey: string, +): Record { + const existing = current[draftKey]; + if (!existing) { + return current; + } + const draft = { + ...existing, + text: "", + attachments: [], + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; +} + +export function clearComposerDraftContent(draftKey: string): void { + updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); +} + export function clearComposerDraft(draftKey: string): void { updateComposerDrafts((current) => { if (!current[draftKey]) { @@ -234,6 +391,39 @@ export function clearComposerDraft(draftKey: string): void { }); } +export function removeComposerDraftsForEnvironment( + drafts: Record, + environmentId: EnvironmentId, +): Record { + const environmentPrefix = `${environmentId}:`; + const newTaskPrefix = `new-task:${environmentId}:`; + return Object.fromEntries( + Object.entries(drafts).filter( + ([draftKey]) => + !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix), + ), + ); +} + +export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + + const next = removeComposerDraftsForEnvironment( + appAtomRegistry.get(composerDraftsAtom), + environmentId, + ); + + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + appAtomRegistry.set(composerDraftsAtom, next); + await writePersistedComposerDrafts(next); +} + export function useComposerDraft(draftKey: string | null): ComposerDraft { const drafts = useAtomValue(composerDraftsAtom); useEffect(() => { diff --git a/apps/mobile/src/state/use-composer-path-search.ts b/apps/mobile/src/state/use-composer-path-search.ts index a42143a427b..485b472dcb0 100644 --- a/apps/mobile/src/state/use-composer-path-search.ts +++ b/apps/mobile/src/state/use-composer-path-search.ts @@ -1,46 +1,7 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type ComposerPathSearchState, - type ComposerPathSearchTarget, - EMPTY_COMPOSER_PATH_SEARCH_ATOM, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - composerPathSearchStateAtom, - createComposerPathSearchManager, - getComposerPathSearchTargetKey, - normalizeComposerPathSearchQuery, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +import { type ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; -const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; - -const composerPathSearchManager = createComposerPathSearchManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, -}); - -export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getComposerPathSearchTargetKey(stableTarget); - - useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue( - targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, - ); - return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + return useComposerPathSearchQuery(target); } diff --git a/apps/mobile/src/state/use-environment-runtime.ts b/apps/mobile/src/state/use-environment-runtime.ts deleted file mode 100644 index f4a65a0d283..00000000000 --- a/apps/mobile/src/state/use-environment-runtime.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_ENVIRONMENT_RUNTIME_ATOM, - EMPTY_ENVIRONMENT_RUNTIME_STATE, - createEnvironmentRuntimeManager, - environmentRuntimeStateAtom, - getEnvironmentRuntimeTargetKey, - type EnvironmentRuntimeState, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; - -export const environmentRuntimeManager = createEnvironmentRuntimeManager({ - getRegistry: () => appAtomRegistry, -}); - -export function useEnvironmentRuntime( - environmentId: EnvironmentId | null, -): EnvironmentRuntimeState { - const targetKey = getEnvironmentRuntimeTargetKey({ environmentId }); - const state = useAtomValue( - targetKey !== null ? environmentRuntimeStateAtom(targetKey) : EMPTY_ENVIRONMENT_RUNTIME_ATOM, - ); - return targetKey === null ? EMPTY_ENVIRONMENT_RUNTIME_STATE : state; -} - -export function useEnvironmentRuntimeStates( - environmentIds: ReadonlyArray, -): Readonly> { - const stableEnvironmentIds = useMemo( - () => Arr.sort(new Set(environmentIds), Order.String), - [environmentIds], - ); - const snapshotCacheRef = useRef>>({}); - - const subscribe = useCallback( - (onStoreChange: () => void) => { - const unsubs = stableEnvironmentIds.map((environmentId) => - appAtomRegistry.subscribe(environmentRuntimeStateAtom(environmentId), onStoreChange), - ); - return () => { - for (const unsub of unsubs) { - unsub(); - } - }; - }, - [stableEnvironmentIds], - ); - - const getSnapshot = useCallback(() => { - const previous = snapshotCacheRef.current; - let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; - const next: Record = {}; - - for (const environmentId of stableEnvironmentIds) { - const snapshot = environmentRuntimeManager.getSnapshot({ environmentId }); - next[environmentId] = snapshot; - if (!hasChanged && previous[environmentId] !== snapshot) { - hasChanged = true; - } - } - - if (!hasChanged) { - return previous; - } - - snapshotCacheRef.current = next; - return next; - }, [stableEnvironmentIds]); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/apps/mobile/src/state/use-filesystem-browse.ts b/apps/mobile/src/state/use-filesystem-browse.ts deleted file mode 100644 index e5ab77a80af..00000000000 --- a/apps/mobile/src/state/use-filesystem-browse.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_FILESYSTEM_BROWSE_ATOM, - EMPTY_FILESYSTEM_BROWSE_STATE, - type FilesystemBrowseClient, - type FilesystemBrowseState, - type FilesystemBrowseTarget, - createFilesystemBrowseManager, - filesystemBrowseStateAtom, - getFilesystemBrowseTargetKey, -} from "@t3tools/client-runtime"; -import type { - EnvironmentId, - FilesystemBrowseInput, - FilesystemBrowseResult, -} from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const filesystemBrowseManager = createFilesystemBrowseManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.filesystem ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -function filesystemBrowseTargetForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): FilesystemBrowseTarget { - return { key: environmentId, input }; -} - -export function refreshFilesystemBrowseForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, - client?: FilesystemBrowseClient | null, -): Promise { - return filesystemBrowseManager.refresh( - filesystemBrowseTargetForEnvironment(environmentId, input), - client ?? undefined, - ); -} - -export function invalidateFilesystemBrowseForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): void { - filesystemBrowseManager.invalidate(filesystemBrowseTargetForEnvironment(environmentId, input)); -} - -export function resetFilesystemBrowseState(): void { - filesystemBrowseManager.reset(); -} - -export function resetFilesystemBrowseStateForTests(): void { - resetFilesystemBrowseState(); -} - -export function useFilesystemBrowse( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): FilesystemBrowseState { - const target = useMemo( - () => filesystemBrowseTargetForEnvironment(environmentId, input), - [environmentId, input], - ); - - useEffect(() => { - return filesystemBrowseManager.watch(target); - }, [target]); - - const targetKey = getFilesystemBrowseTargetKey(target); - const state = useAtomValue( - targetKey !== null ? filesystemBrowseStateAtom(targetKey) : EMPTY_FILESYSTEM_BROWSE_ATOM, - ); - return targetKey === null ? EMPTY_FILESYSTEM_BROWSE_STATE : state; -} diff --git a/apps/mobile/src/state/use-remote-catalog.ts b/apps/mobile/src/state/use-remote-catalog.ts deleted file mode 100644 index 8a5ddac2c0f..00000000000 --- a/apps/mobile/src/state/use-remote-catalog.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { useMemo } from "react"; -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; - -import { - EnvironmentConnectionState, - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - scopeProjectShell, - scopeThreadShell, -} from "@t3tools/client-runtime"; - -import { ConnectedEnvironmentSummary } from "./remote-runtime-types"; -import type { SavedRemoteConnection } from "../lib/connection"; -import { useCachedShellSnapshotMetadata, useShellSnapshotStates } from "./use-shell-snapshot"; -import { - useRemoteConnectionStatus, - useRemoteEnvironmentState, -} from "./use-remote-environment-registry"; - -const projectsSortOrder = Order.mapInput( - Order.Struct({ - title: Order.String, - environmentId: Order.String, - }), - (project: EnvironmentScopedProjectShell) => ({ - title: project.title, - environmentId: project.environmentId, - }), -); - -const threadsSortOrder = Order.mapInput( - Order.Struct({ - activityAt: Order.flip(Order.String), - environmentId: Order.String, - }), - (thread: EnvironmentScopedThreadShell) => ({ - activityAt: thread.updatedAt ?? thread.createdAt, - environmentId: thread.environmentId, - }), -); - -function deriveOverallConnectionState( - environments: ReadonlyArray, -): EnvironmentConnectionState { - if (environments.length === 0) { - return "idle"; - } - if (environments.some((environment) => environment.connectionState === "ready")) { - return "ready"; - } - if (environments.some((environment) => environment.connectionState === "reconnecting")) { - return "reconnecting"; - } - if (environments.some((environment) => environment.connectionState === "connecting")) { - return "connecting"; - } - return "disconnected"; -} - -function listRemoteCatalogEnvironmentIds( - savedConnectionsById: Readonly>, -): ReadonlyArray { - const environmentIds: SavedRemoteConnection["environmentId"][] = []; - for (const connection of Object.values(savedConnectionsById)) { - environmentIds.push(connection.environmentId); - } - return environmentIds; -} - -export interface RemoteCatalogState { - readonly isLoadingSavedConnections: boolean; - readonly hasSavedConnections: boolean; - readonly hasLoadedShellSnapshot: boolean; - readonly hasPendingShellSnapshot: boolean; - readonly hasReadyEnvironment: boolean; - readonly hasConnectingEnvironment: boolean; - readonly connectionState: EnvironmentConnectionState; - readonly connectionError: string | null; - readonly shellSnapshotError: string | null; - readonly isUsingCachedData: boolean; - readonly latestCachedSnapshotReceivedAt: string | null; -} - -export function useRemoteCatalog() { - const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); - const { environmentStateById, isLoadingSavedConnection, savedConnectionsById } = - useRemoteEnvironmentState(); - const catalogEnvironmentIds = useMemo( - () => listRemoteCatalogEnvironmentIds(savedConnectionsById), - [savedConnectionsById], - ); - const shellSnapshotStates = useShellSnapshotStates(catalogEnvironmentIds); - const cachedShellSnapshotMetadata = useCachedShellSnapshotMetadata(); - - const projects = useMemo(() => { - const scopedProjects: EnvironmentScopedProjectShell[] = []; - for (const connection of Object.values(savedConnectionsById)) { - const projects = shellSnapshotStates[connection.environmentId]?.data?.projects ?? []; - for (const project of projects) { - scopedProjects.push(scopeProjectShell(connection.environmentId, project)); - } - } - return Arr.sort(scopedProjects, projectsSortOrder); - }, [savedConnectionsById, shellSnapshotStates]); - - const threads = useMemo(() => { - const scopedThreads: EnvironmentScopedThreadShell[] = []; - for (const connection of Object.values(savedConnectionsById)) { - const threads = shellSnapshotStates[connection.environmentId]?.data?.threads ?? []; - for (const thread of threads) { - scopedThreads.push(scopeThreadShell(connection.environmentId, thread)); - } - } - return Arr.sort(scopedThreads, threadsSortOrder); - }, [savedConnectionsById, shellSnapshotStates]); - - const serverConfigByEnvironmentId = useMemo( - () => - Object.fromEntries( - Object.entries(environmentStateById).map(([environmentId, runtime]) => [ - environmentId, - runtime.serverConfig ?? null, - ]), - ), - [environmentStateById], - ); - - const overallConnectionState = useMemo( - () => deriveOverallConnectionState(connectedEnvironments), - [connectedEnvironments], - ); - - const hasRemoteActivity = useMemo( - () => - threads.some( - (thread) => thread.session?.status === "running" || thread.session?.status === "starting", - ), - [threads], - ); - - const state = useMemo(() => { - const shellSnapshots = Object.values(shellSnapshotStates); - const cachedSnapshotReceivedAts: string[] = []; - for (const environmentId of catalogEnvironmentIds) { - const metadata = cachedShellSnapshotMetadata[environmentId]; - if (metadata) { - cachedSnapshotReceivedAts.push(metadata.snapshotReceivedAt); - } - } - let shellSnapshotError: string | null = null; - for (const snapshot of shellSnapshots) { - if (snapshot.error !== null) { - shellSnapshotError = snapshot.error; - break; - } - } - return { - isLoadingSavedConnections: isLoadingSavedConnection, - hasSavedConnections: catalogEnvironmentIds.length > 0, - hasLoadedShellSnapshot: shellSnapshots.some((snapshot) => snapshot.data !== null), - hasPendingShellSnapshot: shellSnapshots.some((snapshot) => snapshot.isPending), - hasReadyEnvironment: connectedEnvironments.some( - (environment) => environment.connectionState === "ready", - ), - hasConnectingEnvironment: connectedEnvironments.some( - (environment) => - environment.connectionState === "connecting" || - environment.connectionState === "reconnecting", - ), - connectionState: connectionState ?? overallConnectionState, - connectionError, - shellSnapshotError, - isUsingCachedData: cachedSnapshotReceivedAts.length > 0, - latestCachedSnapshotReceivedAt: - Arr.sort(cachedSnapshotReceivedAts, Order.flip(Order.String))[0] ?? null, - }; - }, [ - cachedShellSnapshotMetadata, - catalogEnvironmentIds, - connectedEnvironments, - connectionError, - connectionState, - isLoadingSavedConnection, - overallConnectionState, - shellSnapshotStates, - ]); - - return { - projects, - threads, - serverConfigByEnvironmentId, - connectionState: state.connectionState, - connectionError: state.connectionError, - state, - hasRemoteActivity, - }; -} diff --git a/apps/mobile/src/state/use-remote-environment-registry.test.ts b/apps/mobile/src/state/use-remote-environment-registry.test.ts deleted file mode 100644 index fc465bbfb88..00000000000 --- a/apps/mobile/src/state/use-remote-environment-registry.test.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; -import { - createManagedRelaySession, - ManagedRelayDpopSigner, - setManagedRelaySession, -} from "@t3tools/client-runtime"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => { - const environmentConnection = { - ensureBootstrapped: vi.fn(() => Promise.resolve()), - dispose: vi.fn(() => Promise.resolve()), - }; - const sessionConnection = { - dispose: vi.fn(() => Promise.resolve()), - reconnect: vi.fn(() => Promise.resolve()), - }; - const sessionClient = { - isHeartbeatFresh: vi.fn(() => false), - }; - return { - environmentConnection, - sessionConnection, - sessionClient, - createEnvironmentConnection: vi.fn(() => environmentConnection), - createKnownEnvironment: vi.fn((input: unknown) => input), - createWsRpcClient: vi.fn(() => ({ rpc: true })), - wsTransportConstructor: vi.fn(), - resolveRemoteWebSocketConnectionUrl: vi.fn(() => ({ _tag: "remote-ws-url-effect" })), - resolveRemoteDpopWebSocketConnectionUrl: vi.fn(), - remoteEndpointUrl: vi.fn((baseUrl: string, path: string) => new URL(path, baseUrl).toString()), - createDpopProof: vi.fn(), - refreshCloudEnvironmentConnection: vi.fn(), - bootstrapRemoteConnection: vi.fn(), - clearCachedShellSnapshot: vi.fn(() => Promise.resolve()), - clearSavedConnection: vi.fn(() => Promise.resolve()), - saveConnection: vi.fn((_connection?: unknown) => Promise.resolve()), - saveCachedShellSnapshot: vi.fn(() => Promise.resolve()), - mobileRunPromise: vi.fn((_effect?: unknown) => - Promise.resolve("wss://desktop.example/ws?wsTicket=token"), - ), - removeEnvironmentSession: vi.fn(() => null), - getEnvironmentSession: vi.fn(() => null), - setEnvironmentSession: vi.fn(), - notifyEnvironmentConnectionListeners: vi.fn(), - unregisterAgentAwarenessConnection: vi.fn(), - registerAgentAwarenessConnection: vi.fn(), - shellSnapshotInvalidate: vi.fn(), - shellSnapshotMarkPending: vi.fn(), - environmentRuntimeInvalidate: vi.fn(), - environmentRuntimePatch: vi.fn(), - clearCachedShellSnapshotMetadata: vi.fn(), - invalidateSourceControlDiscoveryForEnvironment: vi.fn(), - terminalSessionInvalidateEnvironment: vi.fn(), - subscribeTerminalMetadata: vi.fn(() => vi.fn()), - terminalDebugLog: vi.fn(), - WsTransport: function WsTransport(...args: ReadonlyArray) { - mocks.wsTransportConstructor(...args); - }, - }; -}); - -vi.mock("react-native", () => ({ - Alert: { - alert: vi.fn(), - }, - AppState: { - currentState: "active", - addEventListener: vi.fn(() => ({ remove: vi.fn() })), - }, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - WsTransport: mocks.WsTransport, - createEnvironmentConnection: mocks.createEnvironmentConnection, - createKnownEnvironment: mocks.createKnownEnvironment, - createWsRpcClient: mocks.createWsRpcClient, - remoteEndpointUrl: mocks.remoteEndpointUrl, - resolveRemoteDpopWebSocketConnectionUrl: mocks.resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl: mocks.resolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../lib/connection", async (importOriginal) => ({ - ...(await importOriginal()), - bootstrapRemoteConnection: mocks.bootstrapRemoteConnection, -})); - -vi.mock("../features/cloud/linkEnvironment", () => ({ - refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection, -})); - -vi.mock("../lib/storage", () => ({ - clearCachedShellSnapshot: mocks.clearCachedShellSnapshot, - clearSavedConnection: mocks.clearSavedConnection, - loadCachedShellSnapshot: vi.fn(() => Promise.resolve(null)), - loadSavedConnections: vi.fn(() => Promise.resolve([])), - saveCachedShellSnapshot: mocks.saveCachedShellSnapshot, - saveConnection: mocks.saveConnection, -})); - -vi.mock("../lib/runtime", () => ({ - mobileRuntime: { - runPromise: mocks.mobileRunPromise, - }, -})); - -vi.mock("./environment-session-registry", () => ({ - drainEnvironmentSessions: vi.fn(() => []), - getEnvironmentSession: mocks.getEnvironmentSession, - notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners, - removeEnvironmentSession: mocks.removeEnvironmentSession, - setEnvironmentSession: mocks.setEnvironmentSession, -})); - -vi.mock("../features/agent-awareness/remoteRegistration", () => ({ - registerAgentAwarenessConnection: mocks.registerAgentAwarenessConnection, - unregisterAgentAwarenessConnection: mocks.unregisterAgentAwarenessConnection, - unregisterAllAgentAwarenessConnections: vi.fn(), -})); - -vi.mock("../features/terminal/terminalDebugLog", () => ({ - terminalDebugLog: mocks.terminalDebugLog, -})); - -vi.mock("./use-environment-runtime", () => ({ - environmentRuntimeManager: { - invalidate: mocks.environmentRuntimeInvalidate, - patch: mocks.environmentRuntimePatch, - }, - useEnvironmentRuntimeStates: vi.fn(() => ({})), -})); - -vi.mock("./use-shell-snapshot", () => ({ - clearCachedShellSnapshotMetadata: mocks.clearCachedShellSnapshotMetadata, - hydrateCachedShellSnapshot: vi.fn(), - markShellSnapshotLive: vi.fn(), - shellSnapshotManager: { - applyEvent: vi.fn(), - invalidate: mocks.shellSnapshotInvalidate, - markPending: mocks.shellSnapshotMarkPending, - syncSnapshot: vi.fn(), - }, -})); - -vi.mock("./use-source-control-discovery", () => ({ - invalidateSourceControlDiscoveryForEnvironment: - mocks.invalidateSourceControlDiscoveryForEnvironment, - resetSourceControlDiscoveryState: vi.fn(), -})); - -vi.mock("./use-terminal-session", () => ({ - subscribeTerminalMetadata: mocks.subscribeTerminalMetadata, - terminalSessionManager: { - invalidate: vi.fn(), - invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment, - }, -})); - -import { - connectSavedEnvironment, - disconnectEnvironment, - reconnectEnvironmentConnectionsAfterAppResume, -} from "./use-remote-environment-registry"; -import { appAtomRegistry } from "./atom-registry"; - -const environmentId = EnvironmentId.make("env-mobile-test"); - -const connection = { - environmentId, - environmentLabel: "Mobile Test Desktop", - pairingUrl: "https://desktop.example/", - displayUrl: "https://desktop.example/", - httpBaseUrl: "https://desktop.example/", - wsBaseUrl: "wss://desktop.example/", - bearerToken: "remote-access-token", -} as const; - -describe("mobile remote environment registry effects", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.createEnvironmentConnection.mockReturnValue(mocks.environmentConnection); - mocks.environmentConnection.ensureBootstrapped.mockResolvedValue(undefined); - mocks.environmentConnection.dispose.mockResolvedValue(undefined); - mocks.sessionConnection.dispose.mockResolvedValue(undefined); - mocks.sessionConnection.reconnect.mockResolvedValue(undefined); - mocks.sessionClient.isHeartbeatFresh.mockReturnValue(false); - mocks.removeEnvironmentSession.mockReturnValue(null); - mocks.getEnvironmentSession.mockReturnValue(null); - mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token"); - mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof")); - mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.die("unexpected refresh")); - mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( - Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"), - ); - setManagedRelaySession(appAtomRegistry, null); - }); - - it.effect("connects a saved managed endpoint environment through Effect-wrapped APIs", () => - Effect.gen(function* () { - yield* connectSavedEnvironment(connection); - - expect(mocks.saveConnection).toHaveBeenCalledWith(connection); - expect(mocks.wsTransportConstructor).toHaveBeenCalledTimes(1); - expect(mocks.createEnvironmentConnection).toHaveBeenCalledTimes(1); - expect(mocks.setEnvironmentSession).toHaveBeenCalledWith( - connection.environmentId, - expect.objectContaining({ - connection: mocks.environmentConnection, - }), - ); - expect(mocks.subscribeTerminalMetadata).toHaveBeenCalledWith( - expect.objectContaining({ environmentId: connection.environmentId }), - ); - expect(mocks.registerAgentAwarenessConnection).toHaveBeenCalledWith(connection); - expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); - }), - ); - - it.effect("uses DPoP-bound admission for a managed DPoP connection", () => - Effect.gen(function* () { - const dpopConnection = { - ...connection, - bearerToken: null, - authenticationMethod: "dpop", - dpopAccessToken: "environment-dpop-token", - } as const; - mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => - Effect.runPromise( - (effect as Effect.Effect).pipe( - Effect.provideService( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("mobile-key-thumbprint"), - createProof: mocks.createDpopProof, - }), - ), - ), - ), - ); - - yield* connectSavedEnvironment(dpopConnection); - const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as - | (() => Promise) - | undefined; - expect(openSocket).toBeDefined(); - yield* Effect.promise(() => openSocket!()); - - expect(mocks.createDpopProof).toHaveBeenCalledWith({ - method: "POST", - url: "https://desktop.example/api/auth/websocket-ticket", - accessToken: "environment-dpop-token", - }); - expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ - wsBaseUrl: dpopConnection.wsBaseUrl, - httpBaseUrl: dpopConnection.httpBaseUrl, - accessToken: "environment-dpop-token", - dpopProof: "dpop-proof", - }); - expect(mocks.resolveRemoteWebSocketConnectionUrl).not.toHaveBeenCalled(); - }), - ); - - it.effect("refreshes a persisted managed connection before reconnecting", () => - Effect.gen(function* () { - const savedDpopConnection = { - ...connection, - bearerToken: null, - authenticationMethod: "dpop", - relayManaged: true, - } as const; - const refreshedConnection = { - ...savedDpopConnection, - displayUrl: "https://rotated-desktop.example/", - httpBaseUrl: "https://rotated-desktop.example/", - wsBaseUrl: "wss://rotated-desktop.example/", - dpopAccessToken: "fresh-environment-dpop-token", - } as const; - setManagedRelaySession( - appAtomRegistry, - createManagedRelaySession({ - accountId: "account-1", - readClerkToken: () => Promise.resolve("fresh-clerk-token"), - }), - ); - mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.succeed(refreshedConnection)); - mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => - Effect.runPromise( - (effect as Effect.Effect).pipe( - Effect.provideService( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("mobile-key-thumbprint"), - createProof: mocks.createDpopProof, - }), - ), - ), - ), - ); - - yield* connectSavedEnvironment(savedDpopConnection, { persist: false }); - const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as - | (() => Promise) - | undefined; - expect(openSocket).toBeDefined(); - yield* Effect.promise(() => openSocket!()); - - expect(mocks.refreshCloudEnvironmentConnection).toHaveBeenCalledWith({ - clerkToken: "fresh-clerk-token", - connection: savedDpopConnection, - }); - const persistedConnection = mocks.saveConnection.mock.calls[0]?.[0]; - expect(persistedConnection).toMatchObject({ - ...savedDpopConnection, - displayUrl: refreshedConnection.displayUrl, - httpBaseUrl: refreshedConnection.httpBaseUrl, - wsBaseUrl: refreshedConnection.wsBaseUrl, - }); - expect(persistedConnection).not.toHaveProperty("dpopAccessToken"); - expect(mocks.createDpopProof).toHaveBeenCalledWith({ - method: "POST", - url: "https://rotated-desktop.example/api/auth/websocket-ticket", - accessToken: "fresh-environment-dpop-token", - }); - expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ - wsBaseUrl: refreshedConnection.wsBaseUrl, - httpBaseUrl: refreshedConnection.httpBaseUrl, - accessToken: "fresh-environment-dpop-token", - dpopProof: "dpop-proof", - }); - }), - ); - - it.effect("fails interactive connects when the managed endpoint bootstrap fails", () => - Effect.gen(function* () { - mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( - new Error("bootstrap failed"), - ); - mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ - connection: mocks.sessionConnection, - } as never); - - const result = yield* Effect.exit(connectSavedEnvironment(connection)); - - expect(result._tag).toBe("Failure"); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); - expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); - }), - ); - - it.effect("can suppress bootstrap failures during best-effort startup reconnect", () => - Effect.gen(function* () { - mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( - new Error("bootstrap failed"), - ); - mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ - connection: mocks.sessionConnection, - } as never); - - yield* connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }); - - expect(mocks.saveConnection).not.toHaveBeenCalled(); - expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); - expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - }), - ); - - it.effect("reconnects a stale saved environment session after app resume", () => - Effect.gen(function* () { - yield* connectSavedEnvironment(connection); - vi.clearAllMocks(); - mocks.getEnvironmentSession.mockReturnValue({ - client: mocks.sessionClient, - connection: mocks.sessionConnection, - } as never); - - reconnectEnvironmentConnectionsAfterAppResume("test"); - - yield* Effect.promise(() => - vi.waitFor(() => { - expect(mocks.sessionConnection.reconnect).toHaveBeenCalledTimes(1); - }), - ); - expect(mocks.shellSnapshotMarkPending).toHaveBeenCalledWith({ - environmentId: connection.environmentId, - }); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - }), - ); - - it.effect("disconnects and removes persisted managed endpoint state when requested", () => - Effect.gen(function* () { - mocks.removeEnvironmentSession.mockReturnValue({ - connection: mocks.sessionConnection, - } as never); - - yield* disconnectEnvironment(connection.environmentId, { removeSaved: true }); - - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.unregisterAgentAwarenessConnection).toHaveBeenCalledWith( - connection.environmentId, - ); - expect(mocks.clearSavedConnection).toHaveBeenCalledWith(connection.environmentId); - expect(mocks.clearCachedShellSnapshot).toHaveBeenCalledWith(connection.environmentId); - expect(mocks.clearCachedShellSnapshotMetadata).toHaveBeenCalledWith(connection.environmentId); - }), - ); -}); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index b7584858dc4..6fb41fc091f 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,90 +1,26 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo } from "react"; -import { Alert, AppState } from "react-native"; - -import { - type EnvironmentRuntimeState, - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - createKnownEnvironment, - createWsRpcClient, - EnvironmentConnectionState, - ManagedRelayDpopSigner, - WsTransport, - remoteEndpointUrl, - resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl, - waitForManagedRelayClerkToken, -} from "@t3tools/client-runtime"; +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Order from "effect/Order"; +import type { ServerConfig } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; -import { pipe } from "effect/Function"; -import { Atom } from "effect/unstable/reactivity"; -import { - type SavedRemoteConnection, - bootstrapRemoteConnection, - isRelayManagedConnection, - toStableSavedRemoteConnection, -} from "../lib/connection"; -import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment"; -import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; +import { Alert } from "react-native"; + +import { useEnvironmentServerConfig } from "../state/entities"; +import { useConnectionController } from "../features/connection/useConnectionController"; +import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { - clearCachedShellSnapshot, - clearSavedConnection, - loadCachedShellSnapshot, - loadSavedConnections, - saveCachedShellSnapshot, - saveConnection, -} from "../lib/storage"; + projectEnvironmentPresentation, + type EnvironmentPresentation, +} from "../state/environments"; +import { useWorkspaceState } from "../state/workspace"; +import type { SavedRemoteConnection } from "../lib/connection"; import { appAtomRegistry } from "./atom-registry"; -import { mobileRuntime } from "../lib/runtime"; -import { - drainEnvironmentSessions, - getEnvironmentSession, - notifyEnvironmentConnectionListeners, - removeEnvironmentSession, - setEnvironmentSession, -} from "./environment-session-registry"; -import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; -import { - invalidateSourceControlDiscoveryForEnvironment, - resetSourceControlDiscoveryState, -} from "./use-source-control-discovery"; -import { - registerAgentAwarenessConnection, - unregisterAgentAwarenessConnection, - unregisterAllAgentAwarenessConnections, -} from "../features/agent-awareness/remoteRegistration"; -import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; -import { - clearCachedShellSnapshotMetadata, - hydrateCachedShellSnapshot, - markShellSnapshotLive, - shellSnapshotManager, -} from "./use-shell-snapshot"; -import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session"; - -const terminalMetadataUnsubscribers = new Map void>(); -const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); -const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000; -const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000; -let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY; - -interface RemoteEnvironmentLocalState { - readonly isLoadingSavedConnection: boolean; - readonly connectionPairingUrl: string; - readonly pendingConnectionError: string | null; - readonly savedConnectionsById: Record; -} - -const isLoadingSavedConnectionAtom = Atom.make(true).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:is-loading-saved-connection"), -); +import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types"; +import { environmentSession, usePreparedConnection } from "./session"; +import { environmentCatalog } from "../connection/catalog"; const connectionPairingUrlAtom = Atom.make("").pipe( Atom.keepAlive, @@ -96,680 +32,191 @@ const pendingConnectionErrorAtom = Atom.make(null).pipe( Atom.withLabel("mobile:pending-connection-error"), ); -const savedConnectionsByIdAtom = Atom.make>({}).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:saved-connections"), -); - -function getSavedConnectionsById(): Record { - return appAtomRegistry.get(savedConnectionsByIdAtom); -} - -function setIsLoadingSavedConnection(value: boolean): void { - appAtomRegistry.set(isLoadingSavedConnectionAtom, value); -} - -function setConnectionPairingUrl(pairingUrl: string): void { - appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); -} - -function clearConnectionPairingUrl(): void { - appAtomRegistry.set(connectionPairingUrlAtom, ""); -} - export function setPendingConnectionError(message: string | null): void { appAtomRegistry.set(pendingConnectionErrorAtom, message); } -function clearPendingConnectionError(): void { - appAtomRegistry.set(pendingConnectionErrorAtom, null); -} +function toSavedConnection( + environment: EnvironmentPresentation, + prepared: Option.Option, +): SavedRemoteConnection { + const displayUrl = environment.displayUrl ?? ""; + const active = Option.getOrNull(prepared); + const httpBaseUrl = active?.httpBaseUrl ?? displayUrl; + const socketUrl = active?.socketUrl ?? ""; + const wsBaseUrl = + socketUrl === "" + ? displayUrl.startsWith("https://") + ? displayUrl.replace(/^https:/, "wss:") + : displayUrl.replace(/^http:/, "ws:") + : new URL(socketUrl).origin; + const authorization = active?.httpAuthorization ?? null; -function replaceSavedConnections(connections: Record): void { - appAtomRegistry.set(savedConnectionsByIdAtom, connections); -} - -function upsertSavedConnection(connection: SavedRemoteConnection): void { - const current = appAtomRegistry.get(savedConnectionsByIdAtom); - appAtomRegistry.set(savedConnectionsByIdAtom, { - ...current, - [connection.environmentId]: connection, - }); + return { + environmentId: environment.environmentId, + environmentLabel: environment.label, + pairingUrl: displayUrl, + displayUrl, + httpBaseUrl, + wsBaseUrl, + bearerToken: authorization?._tag === "Bearer" ? authorization.token : null, + ...(environment.relayManaged + ? { + authenticationMethod: "dpop" as const, + relayManaged: true as const, + ...(authorization?._tag === "Dpop" ? { dpopAccessToken: authorization.accessToken } : {}), + } + : { authenticationMethod: "bearer" as const }), + }; } -function removeSavedConnection(environmentId: EnvironmentId): void { - const current = appAtomRegistry.get(savedConnectionsByIdAtom); - const next = { ...current }; - delete next[environmentId]; - appAtomRegistry.set(savedConnectionsByIdAtom, next); +const savedConnectionsByIdAtom = Atom.make((get) => { + const presentationById = get(environmentPresentations.presentationsAtom); + return Object.fromEntries( + [...presentationById.entries()].map(([environmentId, presentation]) => [ + environmentId, + toSavedConnection( + projectEnvironmentPresentation(environmentId, presentation), + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ), + ]), + ) as Record; +}).pipe(Atom.withLabel("mobile:saved-connections-by-id")); + +function toRuntimeState( + environment: EnvironmentPresentation, + serverConfig: ServerConfig | null, +): EnvironmentRuntimeState { + return { + connectionState: environment.connection.phase, + connectionError: environment.connection.error, + connectionErrorTraceId: environment.connection.traceId, + serverConfig, + }; } -function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState { - const isLoadingSavedConnection = useAtomValue(isLoadingSavedConnectionAtom); - const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); - const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); +export function useSavedRemoteConnections() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); - return useMemo( - () => ({ - isLoadingSavedConnection, - connectionPairingUrl, - pendingConnectionError, - savedConnectionsById, - }), - [connectionPairingUrl, isLoadingSavedConnection, pendingConnectionError, savedConnectionsById], - ); -} - -function setEnvironmentConnectionStatus( - environmentId: EnvironmentId, - state: ConnectedEnvironmentSummary["connectionState"], - error?: string | null, -) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionState: state, - connectionError: error === undefined ? current.connectionError : error, - })); -} - -function fromPromise(tryPromise: () => Promise): Effect.Effect { - return Effect.tryPromise({ - try: tryPromise, - catch: (cause) => cause, - }); -} - -export function disconnectEnvironment( - environmentId: EnvironmentId, - options?: { - readonly preserveShellSnapshot?: boolean; - readonly removeSaved?: boolean; - readonly preserveConnectionAttempt?: boolean; - }, -): Effect.Effect { - return Effect.gen(function* () { - if (!options?.preserveConnectionAttempt) { - environmentConnectionAttempts.cancel(environmentId); - } - - const session = removeEnvironmentSession(environmentId); - notifyEnvironmentConnectionListeners(); - if (session) { - yield* fromPromise(() => session.connection.dispose()); - } - terminalMetadataUnsubscribers.get(environmentId)?.(); - terminalMetadataUnsubscribers.delete(environmentId); - unregisterAgentAwarenessConnection(environmentId); - if (!options?.preserveShellSnapshot) { - shellSnapshotManager.invalidate({ environmentId }); - } - invalidateSourceControlDiscoveryForEnvironment(environmentId); - terminalSessionManager.invalidateEnvironment(environmentId); - environmentRuntimeManager.invalidate({ environmentId }); - - if (options?.removeSaved) { - yield* Effect.all( - [ - fromPromise(() => clearSavedConnection(environmentId)), - fromPromise(() => clearCachedShellSnapshot(environmentId)), - ], - { concurrency: 2 }, - ); - clearCachedShellSnapshotMetadata(environmentId); - removeSavedConnection(environmentId); - } - }); -} - -export function connectSavedEnvironment( - connection: SavedRemoteConnection, - options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean }, -): Effect.Effect { - return Effect.gen(function* () { - const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); - const isCurrentAttempt = connectionAttempt.isCurrent; - let activeConnection = connection; - let initialDpopAccessToken = - options?.persist === false ? undefined : connection.dpopAccessToken; - - yield* disconnectEnvironment(connection.environmentId, { - preserveShellSnapshot: true, - preserveConnectionAttempt: true, - }); - if (!isCurrentAttempt()) { - return; - } - - if (options?.persist !== false) { - yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection))); - if (!isCurrentAttempt()) { - return; - } - } - - upsertSavedConnection(toStableSavedRemoteConnection(connection)); - setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); - shellSnapshotManager.markPending({ environmentId: connection.environmentId }); - - const transport = new WsTransport( - () => - mobileRuntime.runPromise( - isRelayManagedConnection(connection) - ? Effect.gen(function* () { - let dpopAccessToken = initialDpopAccessToken; - initialDpopAccessToken = undefined; - if (!dpopAccessToken) { - const clerkToken = yield* waitForManagedRelayClerkToken(appAtomRegistry); - const refreshedConnection = yield* refreshCloudEnvironmentConnection({ - clerkToken, - connection: activeConnection, - }); - const stableConnection = toStableSavedRemoteConnection(refreshedConnection); - activeConnection = refreshedConnection; - if (isCurrentAttempt()) { - yield* fromPromise(() => saveConnection(stableConnection)); - upsertSavedConnection(stableConnection); - } - dpopAccessToken = refreshedConnection.dpopAccessToken; - } - if (!dpopAccessToken) { - return yield* Effect.fail( - new Error("Managed environment connection did not return a DPoP access token."), - ); - } - const signer = yield* ManagedRelayDpopSigner; - const dpop = yield* signer.createProof({ - method: "POST", - url: remoteEndpointUrl( - activeConnection.httpBaseUrl, - "/api/auth/websocket-ticket", - ), - accessToken: dpopAccessToken, - }); - return yield* resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: activeConnection.wsBaseUrl, - httpBaseUrl: activeConnection.httpBaseUrl, - accessToken: dpopAccessToken, - dpopProof: dpop, - }); - }) - : resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: connection.wsBaseUrl, - httpBaseUrl: connection.httpBaseUrl, - bearerToken: connection.bearerToken ?? "", - }), - ), - { - onAttempt: () => { - if (!isCurrentAttempt()) { - return; - } - - environmentRuntimeManager.patch( - { environmentId: connection.environmentId }, - (previous) => { - const nextState = - previous.connectionState === "ready" || previous.connectionState === "reconnecting" - ? "reconnecting" - : "connecting"; - const keepSettledFailure = - previous.connectionState === "disconnected" && previous.connectionError !== null; - return { - ...previous, - connectionState: keepSettledFailure ? "disconnected" : nextState, - connectionError: keepSettledFailure ? previous.connectionError : null, - }; - }, - ); - }, - onError: (message) => { - if (isCurrentAttempt()) { - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); - } - }, - onClose: (details) => { - if (!isCurrentAttempt()) { - return; - } - - const reason = - details.reason.trim().length > 0 - ? details.reason - : details.code === 1000 - ? null - : `Remote connection closed (${details.code}).`; - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); - }, - }, - ); - - const client = createWsRpcClient(transport); - const environmentConnection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...createKnownEnvironment({ - id: connection.environmentId, - label: connection.environmentLabel, - source: "manual", - target: { - httpBaseUrl: connection.httpBaseUrl, - wsBaseUrl: connection.wsBaseUrl, - }, - }), - environmentId: connection.environmentId, - }, - client, - applyShellEvent: (event, environmentId) => { - if (isCurrentAttempt()) { - shellSnapshotManager.applyEvent({ environmentId }, event); - } - }, - syncShellSnapshot: (snapshot, environmentId) => { - if (!isCurrentAttempt()) { - return; - } - - shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); - markShellSnapshotLive(environmentId); - void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined); - environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ - ...runtime, - connectionState: "ready", - connectionError: null, - })); - }, - onShellResubscribe: (environmentId) => { - if (isCurrentAttempt()) { - shellSnapshotManager.markPending({ environmentId }); - } - }, - onConfigSnapshot: (serverConfig) => { - if (isCurrentAttempt()) { - environmentRuntimeManager.patch( - { environmentId: connection.environmentId }, - (runtime) => ({ - ...runtime, - serverConfig, - }), - ); - } - }, - }); - - if (!isCurrentAttempt()) { - yield* fromPromise(() => environmentConnection.dispose()); - return; - } - - setEnvironmentSession(connection.environmentId, { - client, - connection: environmentConnection, - }); - - const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe( - Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail(new Error("Environment did not respond before the connection timeout.")), - onSome: Effect.succeed, - }), - ), - Effect.tapError((error: unknown) => - isCurrentAttempt() - ? Effect.gen(function* () { - setEnvironmentConnectionStatus( - connection.environmentId, - "disconnected", - error instanceof Error ? error.message : "Failed to bootstrap remote connection.", - ); - const pendingSession = removeEnvironmentSession(connection.environmentId); - notifyEnvironmentConnectionListeners(); - if (pendingSession) { - yield* fromPromise(() => pendingSession.connection.dispose()); - } - }) - : Effect.void, - ), - ); - const bootstrapped = yield* options?.suppressBootstrapError - ? bootstrap.pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - : bootstrap.pipe(Effect.as(true)); - - if (!bootstrapped || !isCurrentAttempt()) { - return; - } - - terminalMetadataUnsubscribers.set( - connection.environmentId, - subscribeTerminalMetadata({ - environmentId: connection.environmentId, - client, - }), - ); - terminalDebugLog("registry:terminal-metadata-subscribed", { - environmentId: connection.environmentId, - }); - registerAgentAwarenessConnection(toStableSavedRemoteConnection(activeConnection)); - notifyEnvironmentConnectionListeners(); - }); + return { + isLoadingSavedConnection: !catalog.isReady, + savedConnectionsById, + }; } -export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void { - const now = Date.now(); - if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) { - return; +export function useSavedRemoteConnection( + environmentId: EnvironmentId | null, +): SavedRemoteConnection | null { + const { presentation } = useEnvironmentPresentation(environmentId); + const prepared = usePreparedConnection(environmentId); + if (environmentId === null || presentation === null) { + return null; } - - for (const connection of Object.values(getSavedConnectionsById())) { - const session = getEnvironmentSession(connection.environmentId); - if (session?.client.isHeartbeatFresh()) { - continue; - } - - lastAppResumeReconnectAt = now; - terminalDebugLog("registry:app-resume-reconnect", { - environmentId: connection.environmentId, - reason, - hasSession: session !== null, - }); - - if (!session) { - void mobileRuntime - .runPromise( - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ) - .catch((error: unknown) => { - terminalDebugLog("registry:app-resume-reconnect-failed", { - environmentId: connection.environmentId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - }); - continue; - } - - setEnvironmentConnectionStatus(connection.environmentId, "reconnecting", null); - shellSnapshotManager.markPending({ environmentId: connection.environmentId }); - void session.connection.reconnect().catch((error: unknown) => { - const message = - error instanceof Error ? error.message : "Failed to reconnect remote environment."; - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); - terminalDebugLog("registry:app-resume-reconnect-failed", { - environmentId: connection.environmentId, - reason, - error: message, - }); - }); - } -} - -function subscribeAppResumeReconnects(): () => void { - let previousAppState = AppState.currentState; - const subscription = AppState.addEventListener("change", (nextAppState) => { - const wasInactive = previousAppState !== "active"; - previousAppState = nextAppState; - if (nextAppState === "active" && wasInactive) { - reconnectEnvironmentConnectionsAfterAppResume("appstate"); - } - }); - - return () => subscription.remove(); -} - -const environmentsSortOrder = Order.mapInput( - Order.Struct({ - environmentLabel: Order.String, - }), - (environment: ConnectedEnvironmentSummary) => ({ - environmentLabel: environment.environmentLabel, - }), -); - -function deriveConnectedEnvironments( - savedConnectionsById: Record, - environmentStateById: Record, -): ReadonlyArray { - return Arr.sort( - Object.values(savedConnectionsById).map((connection) => { - const runtime = environmentStateById[connection.environmentId]; - return { - environmentId: connection.environmentId, - environmentLabel: connection.environmentLabel, - displayUrl: connection.displayUrl, - isRelayManaged: isRelayManagedConnection(connection), - connectionState: runtime?.connectionState ?? "idle", - connectionError: runtime?.connectionError ?? null, - }; - }), - environmentsSortOrder, - ); -} - -export function useRemoteEnvironmentBootstrap() { - useEffect(() => { - let cancelled = false; - const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects(); - - void (async () => { - try { - const connections = await loadSavedConnections(); - if (cancelled) { - return; - } - - replaceSavedConnections( - Object.fromEntries( - connections.map((connection) => [connection.environmentId, connection]), - ), - ); - - setIsLoadingSavedConnection(false); - - await Promise.all( - connections.map(async (connection) => { - const cached = await loadCachedShellSnapshot(connection.environmentId); - if (!cancelled && cached) { - hydrateCachedShellSnapshot(cached); - } - }), - ); - - if (cancelled) { - return; - } - - await mobileRuntime.runPromise( - Effect.all( - connections.map((connection) => - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ), - { concurrency: "unbounded" }, - ), - ); - } catch { - if (!cancelled) { - setIsLoadingSavedConnection(false); - } - } - })(); - - return () => { - cancelled = true; - unsubscribeAppResumeReconnects(); - for (const session of drainEnvironmentSessions()) { - void session.connection.dispose(); - } - for (const unsubscribe of terminalMetadataUnsubscribers.values()) { - unsubscribe(); - } - terminalMetadataUnsubscribers.clear(); - environmentConnectionAttempts.clear(); - unregisterAllAgentAwarenessConnections(); - environmentRuntimeManager.invalidate(); - shellSnapshotManager.invalidate(); - resetSourceControlDiscoveryState(); - terminalSessionManager.invalidate(); - notifyEnvironmentConnectionListeners(); - }; - }, []); + return toSavedConnection(projectEnvironmentPresentation(environmentId, presentation), prepared); } -export function useRemoteEnvironmentState() { - const state = useRemoteEnvironmentLocalState(); - const environmentStateById = useEnvironmentRuntimeStates( - Object.values(state.savedConnectionsById).map((connection) => connection.environmentId), - ); - - return useMemo( - () => ({ - ...state, - environmentStateById, - }), - [environmentStateById, state], - ); +export function useRemoteEnvironmentRuntime( + environmentId: EnvironmentId | null, +): EnvironmentRuntimeState | null { + const { presentation } = useEnvironmentPresentation(environmentId); + const serverConfig = useEnvironmentServerConfig(environmentId); + if (environmentId === null || presentation === null) { + return null; + } + return toRuntimeState(projectEnvironmentPresentation(environmentId, presentation), serverConfig); } export function useRemoteConnectionStatus() { - const { environmentStateById, pendingConnectionError, savedConnectionsById } = - useRemoteEnvironmentState(); - - const connectedEnvironments = useMemo( - () => deriveConnectedEnvironments(savedConnectionsById, environmentStateById), - [environmentStateById, savedConnectionsById], - ); - - const connectionState = useMemo(() => { - if (connectedEnvironments.length === 0) { - return "idle"; - } - if (connectedEnvironments.some((environment) => environment.connectionState === "ready")) { - return "ready"; - } - if ( - connectedEnvironments.some((environment) => environment.connectionState === "reconnecting") - ) { - return "reconnecting"; - } - if (connectedEnvironments.some((environment) => environment.connectionState === "connecting")) { - return "connecting"; - } - return "disconnected"; - }, [connectedEnvironments]); - - const connectionError = useMemo( + const workspace = useWorkspaceState(); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); + const connectedEnvironments = useMemo>( () => - pipe( - Arr.appendAll( - [pendingConnectionError], - Arr.map(connectedEnvironments, (environment) => environment.connectionError), - ), - Arr.findFirst((value) => value !== null), - Option.getOrNull, - ), - [connectedEnvironments, pendingConnectionError], + workspace.environments.map((environment) => ({ + environmentId: environment.environmentId, + environmentLabel: environment.environmentLabel, + displayUrl: environment.displayUrl, + isRelayManaged: environment.isRelayManaged, + connectionState: environment.connectionState, + connectionError: environment.connectionError, + connectionErrorTraceId: environment.connectionErrorTraceId, + })), + [workspace.environments], ); return { connectedEnvironments, - connectionState, - connectionError, + connectionState: workspace.state.connectionState, + connectionError: pendingConnectionError ?? workspace.state.connectionError, }; } export function useRemoteConnections() { - const { connectionPairingUrl, pendingConnectionError } = useRemoteEnvironmentState(); + const controller = useConnectionController(); + const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); + const onChangeConnectionPairingUrl = useCallback((pairingUrl: string) => { + appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); + }, []); + const onConnectPress = useCallback( async (pairingUrl?: string) => { - try { - const nextPairingUrl = pairingUrl ?? connectionPairingUrl; - const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); - clearPendingConnectionError(); - await mobileRuntime.runPromise(connectSavedEnvironment(connection)); - clearConnectionPairingUrl(); - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to pair with the environment.", - ); - throw error; + const nextPairingUrl = pairingUrl ?? connectionPairingUrl; + setPendingConnectionError(null); + const result = await controller.connectPairingUrl(nextPairingUrl); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + const message = + error instanceof Error ? error.message : "Failed to pair with the environment."; + setPendingConnectionError(message); + } else { + appAtomRegistry.set(connectionPairingUrlAtom, ""); } + return result; }, - [connectionPairingUrl], + [connectionPairingUrl, controller], ); + const onReconnectEnvironment = useCallback( + (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId), + [controller], + ); const onUpdateEnvironment = useCallback( - async ( + ( environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, - ) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection || isRelayManagedConnection(connection)) { + ) => controller.updateEnvironment(environmentId, updates), + [controller], + ); + + const onRemoveEnvironmentPress = useCallback( + (environmentId: EnvironmentId) => { + const environment = connectedEnvironments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (!environment) { return; } - - const updated: SavedRemoteConnection = { - ...connection, - environmentLabel: updates.label.trim() || connection.environmentLabel, - displayUrl: updates.displayUrl.trim() || connection.displayUrl, - }; - - await saveConnection(updated); - upsertSavedConnection(updated); + Alert.alert( + "Remove environment?", + `Disconnect and forget ${environment.environmentLabel} on this device.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => { + void controller.removeEnvironment(environmentId); + }, + }, + ], + ); }, - [], + [connectedEnvironments, controller], ); - const onReconnectEnvironment = useCallback((environmentId: EnvironmentId) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection) { - return; - } - void mobileRuntime - .runPromise( - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ) - .catch(() => undefined); - }, []); - - const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection) { - return; - } - - Alert.alert( - "Remove environment?", - `Disconnect and forget ${connection.environmentLabel} on this device.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: () => { - void mobileRuntime - .runPromise(disconnectEnvironment(environmentId, { removeSaved: true })) - .catch(() => undefined); - }, - }, - ], - ); - }, []); - return { connectionPairingUrl, connectionState, @@ -777,7 +224,7 @@ export function useRemoteConnections() { pairingConnectionError: pendingConnectionError, connectedEnvironments, connectedEnvironmentCount: connectedEnvironments.length, - onChangeConnectionPairingUrl: setConnectionPairingUrl, + onChangeConnectionPairingUrl, onConnectPress, onReconnectEnvironment, onUpdateEnvironment, diff --git a/apps/mobile/src/state/use-selected-thread-commands.ts b/apps/mobile/src/state/use-selected-thread-commands.ts deleted file mode 100644 index a28d33c65d1..00000000000 --- a/apps/mobile/src/state/use-selected-thread-commands.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { useCallback } from "react"; - -import { - CommandId, - type ModelSelection, - type ProviderInteractionMode, - type RuntimeMode, -} from "@t3tools/contracts"; - -import { uuidv4 } from "../lib/uuid"; -import { environmentRuntimeManager } from "./use-environment-runtime"; -import { getEnvironmentClient } from "./environment-session-registry"; -import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; -import { useThreadSelection } from "./use-thread-selection"; - -export function useSelectedThreadCommands(input: { - readonly refreshSelectedThreadGitStatus: (options?: { - readonly quiet?: boolean; - readonly cwd?: string | null; - }) => Promise; -}) { - const { refreshSelectedThreadGitStatus } = input; - const { selectedThread } = useThreadSelection(); - const { savedConnectionsById } = useRemoteEnvironmentState(); - - const onRefresh = useCallback(async () => { - const targets = selectedThread - ? [selectedThread.environmentId] - : Object.values(savedConnectionsById).map((connection) => connection.environmentId); - - await Promise.all( - targets.map(async (environmentId) => { - const client = getEnvironmentClient(environmentId); - if (!client) { - return; - } - - try { - const serverConfig = await client.server.getConfig(); - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - serverConfig, - connectionError: null, - })); - } catch (error) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionError: - error instanceof Error ? error.message : "Failed to refresh remote data.", - })); - } - }), - ); - - if (selectedThread) { - await refreshSelectedThreadGitStatus({ quiet: true }); - } - }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]); - - const onUpdateThreadModelSelection = useCallback( - async (modelSelection: ModelSelection) => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - modelSelection, - }); - }, - [selectedThread], - ); - - const onUpdateThreadRuntimeMode = useCallback( - async (runtimeMode: RuntimeMode) => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.runtime-mode.set", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - runtimeMode, - createdAt: new Date().toISOString(), - }); - }, - [selectedThread], - ); - - const onUpdateThreadInteractionMode = useCallback( - async (interactionMode: ProviderInteractionMode) => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.interaction-mode.set", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - interactionMode, - createdAt: new Date().toISOString(), - }); - }, - [selectedThread], - ); - - const onStopThread = useCallback(async () => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - if ( - selectedThread.session?.status !== "running" && - selectedThread.session?.status !== "starting" - ) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - ...(selectedThread.session?.activeTurnId - ? { turnId: selectedThread.session.activeTurnId } - : {}), - createdAt: new Date().toISOString(), - }); - }, [selectedThread]); - - const onRenameThread = useCallback( - async (title: string) => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - const trimmed = title.trim(); - if (!trimmed || trimmed === selectedThread.title) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - title: trimmed, - }); - }, - [selectedThread], - ); - - return { - onRefresh, - onUpdateThreadModelSelection, - onUpdateThreadRuntimeMode, - onUpdateThreadInteractionMode, - onRenameThread, - onStopThread, - }; -} diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 18860935f36..f320e9da710 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -1,32 +1,60 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - type VcsRef, type GitActionRequestInput, -} from "@t3tools/client-runtime"; -import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts"; + type VcsActionOperation, + type VcsRef, +} from "@t3tools/client-runtime/state/vcs"; +import type { GitRunStackedActionResult } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useBranches } from "../state/queries"; +import { threadEnvironment } from "../state/threads"; +import { vcsActionManager, vcsEnvironment } from "../state/vcs"; import { uuidv4 } from "../lib/uuid"; -import { getEnvironmentClient } from "./environment-session-registry"; +import { appAtomRegistry } from "./atom-registry"; import { setPendingConnectionError } from "./use-remote-environment-registry"; -import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state"; -import { vcsRefManager } from "./use-vcs-refs"; -import { vcsStatusManager } from "./use-vcs-status"; +import { useAtomCommand } from "./use-atom-command"; +import { showGitActionResult } from "./use-vcs-action-state"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; export function useSelectedThreadGitActions() { + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); + const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); + const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); + const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); const { selectedThread, selectedThreadProject } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const runStackedAction = useAtomCommand( + vcsActionManager.runStackedAction({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadCwd, + }), + { reportFailure: false }, + ); const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; - + const branchTarget = useMemo( + () => ({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadGitRootCwd, + query: null, + }), + [selectedThread?.environmentId, selectedThreadGitRootCwd], + ); + const branchState = useBranches(branchTarget); const updateThreadGitContext = useCallback( async ( thread: NonNullable, @@ -35,20 +63,16 @@ export function useSelectedThreadGitActions() { readonly worktreePath?: string | null; }, ) => { - const client = getEnvironmentClient(thread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: thread.id, - ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), - ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), + return updateThreadMetadata({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), + ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), + }, }); }, - [], + [updateThreadMetadata], ); const refreshSelectedThreadGitStatus = useCallback( @@ -62,266 +86,285 @@ export function useSelectedThreadGitActions() { return null; } - try { - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return null; - } - - const status = await vcsActionManager.refreshStatus( - { environmentId: selectedThread.environmentId, cwd }, - { ...client.vcs, runChangeRequest: client.git.runStackedAction }, - options, - ); - setPendingConnectionError(null); - return status; - } catch (error) { + const target = { environmentId: selectedThread.environmentId, cwd }; + const execute = () => + refreshStatus({ + environmentId: selectedThread.environmentId, + input: { cwd }, + }); + const result = options?.quiet + ? await execute() + : await vcsActionManager.track( + appAtomRegistry, + target, + { + operation: "refresh_status", + label: "Refreshing source control status", + }, + execute, + ); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); const message = error instanceof Error ? error.message : "Failed to refresh git status."; setPendingConnectionError(message); return null; } + setPendingConnectionError(null); + return result.value; }, - [selectedThread, selectedThreadCwd, selectedThreadProject], + [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject], ); useEffect(() => { if (!selectedThread || !selectedThreadProject) { return; } - void refreshSelectedThreadGitStatus({ quiet: true }); }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); const runSelectedThreadGitMutation = useCallback( - async ( - operation: (input: { - readonly thread: EnvironmentScopedThreadShell; - readonly project: EnvironmentScopedProjectShell; + async ( + operation: VcsActionOperation, + label: string, + execute: (input: { + readonly thread: EnvironmentThreadShell; + readonly project: EnvironmentProject; readonly cwd: string; - }) => Promise, + }) => Promise>, + options?: { readonly managedExternally?: boolean }, ): Promise => { - if (!selectedThread || !selectedThreadProject) { + if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { return null; } - const cwd = selectedThreadCwd; - if (!cwd) { - return null; - } - - try { - setPendingConnectionError(null); - return await operation({ + const target = { + environmentId: selectedThread.environmentId, + cwd: selectedThreadCwd, + }; + setPendingConnectionError(null); + const run = () => + execute({ thread: selectedThread, project: selectedThreadProject, - cwd, + cwd: selectedThreadCwd, }); - } catch (error) { + const result = + options?.managedExternally === true + ? await run() + : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); const message = error instanceof Error ? error.message : "Git action failed."; setPendingConnectionError(message); showGitActionResult({ type: "error", title: "Git action failed", description: message }); return null; } + return result.value; }, [selectedThread, selectedThreadCwd, selectedThreadProject], ); const refreshSelectedThreadBranches = useCallback(async (): Promise> => { - if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) { - return []; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return []; - } - - try { - const result = await vcsRefManager.load( - { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null }, - client.vcs, - { limit: 100 }, - ); - return dedupeRemoteBranchesWithLocalMatches(result?.refs ?? []).filter( - (branch) => !branch.isRemote, - ); - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to load branches.", - ); - return []; - } - }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]); + branchState.refresh(); + return dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []).filter( + (branch) => !branch.isRemote, + ); + }, [branchState]); const syncSelectedThreadBranchState = useCallback( async (input: { - readonly thread: EnvironmentScopedThreadShell; + readonly thread: EnvironmentThreadShell; readonly cwd: string; - readonly branchRootCwd?: string | null; readonly nextThreadState?: { readonly branch?: string | null; readonly worktreePath?: string | null; }; - }) => { + }): Promise> => { if (input.nextThreadState) { - await updateThreadGitContext(input.thread, input.nextThreadState); - } - - const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null; - if (branchRootCwd) { - vcsRefManager.invalidate({ - environmentId: input.thread.environmentId, - cwd: branchRootCwd, - query: null, - }); - await refreshSelectedThreadBranches(); + const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState); + if (AsyncResult.isFailure(updateResult)) { + return AsyncResult.failure(updateResult.cause); + } } - + branchState.refresh(); await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); + return AsyncResult.success(undefined); }, - [ - refreshSelectedThreadBranches, - refreshSelectedThreadGitStatus, - selectedThreadProject?.workspaceRoot, - updateThreadGitContext, - ], + [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], ); const onCheckoutSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.switchRef( - { environmentId: thread.environmentId, cwd }, - { refName: branch }, - ); - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result?.refName ?? thread.branch, - worktreePath: selectedThreadWorktreePath, - }, - }); - }); + await runSelectedThreadGitMutation( + "switch_ref", + "Switching branch", + async ({ thread, cwd }) => { + const result = await switchRef({ + environmentId: thread.environmentId, + input: { cwd, refName: branch }, + }); + if (AsyncResult.isFailure(result)) { + return result; + } + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.value.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; + }, + ); }, - [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + [ + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + switchRef, + ], ); const onCreateSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.createRef( - { environmentId: thread.environmentId, cwd }, - { - refName: branch, - switchRef: true, - }, - ); - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result?.refName ?? thread.branch, - worktreePath: selectedThreadWorktreePath, - }, - }); - }); + await runSelectedThreadGitMutation( + "create_ref", + "Creating branch", + async ({ thread, cwd }) => { + const result = await createRef({ + environmentId: thread.environmentId, + input: { cwd, refName: branch, switchRef: true }, + }); + if (AsyncResult.isFailure(result)) { + return result; + } + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.value.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; + }, + ); }, - [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + [ + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + createRef, + ], ); const onCreateSelectedThreadWorktree = useCallback( async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { - await runSelectedThreadGitMutation(async ({ thread, project }) => { - const result = await vcsActionManager.createWorktree( - { environmentId: thread.environmentId, cwd: project.workspaceRoot }, - { - refName: nextWorktree.baseBranch, - newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), - path: null, - }, - ); - if (!result) { - return; - } - - await syncSelectedThreadBranchState({ - thread, - cwd: result.worktree.path, - branchRootCwd: project.workspaceRoot, - nextThreadState: { - branch: result.worktree.refName, - worktreePath: result.worktree.path, - }, - }); - }); + await runSelectedThreadGitMutation( + "create_worktree", + "Creating worktree", + async ({ thread, project }) => { + const result = await createWorktree({ + environmentId: thread.environmentId, + input: { + cwd: project.workspaceRoot, + refName: nextWorktree.baseBranch, + newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), + path: null, + }, + }); + if (AsyncResult.isFailure(result)) { + return result; + } + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd: result.value.worktree.path, + nextThreadState: { + branch: result.value.worktree.refName, + worktreePath: result.value.worktree.path, + }, + }); + return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; + }, + ); }, - [runSelectedThreadGitMutation, syncSelectedThreadBranchState], + [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], ); const onPullSelectedThreadBranch = useCallback(async () => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.pull({ environmentId: thread.environmentId, cwd }); - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - if (result) { + await runSelectedThreadGitMutation( + "pull", + "Pulling latest changes", + async ({ thread, cwd }) => { + const result = await pull({ + environmentId: thread.environmentId, + input: { cwd }, + }); + if (AsyncResult.isFailure(result)) { + return result; + } + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); showGitActionResult({ type: "success", title: - result.status === "skipped_up_to_date" + result.value.status === "skipped_up_to_date" ? "Already up to date" - : `Pulled latest on ${result.refName}`, + : `Pulled latest on ${result.value.refName}`, }); - } - }); - }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); + return result; + }, + ); + }, [pull, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); const onRunSelectedThreadGitAction = useCallback( async (input: GitActionRequestInput): Promise => { - return await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.runChangeRequest( - { environmentId: thread.environmentId, cwd }, - { - actionId: uuidv4(), + const actionId = uuidv4(); + return await runSelectedThreadGitMutation( + "run_change_request", + "Running source control action", + async ({ thread, cwd }) => { + const result = await runStackedAction({ + actionId, action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), - }, - { - gitStatus: vcsStatusManager.getSnapshot({ - environmentId: thread.environmentId, - cwd, - }).data, - }, - ); - if (!result) { - return null; - } - - showGitActionResult({ - type: "success", - title: result.toast.title, - description: result.toast.description, - prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, - }); + }); + if (AsyncResult.isFailure(result)) { + return result; + } - if (result.branch.status === "created" && result.branch.name) { - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result.branch.name, - worktreePath: selectedThreadWorktreePath, - }, + showGitActionResult({ + type: "success", + title: result.value.toast.title, + description: result.value.toast.description, + prUrl: + result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, }); - return result; - } - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - return result; - }); + if (result.value.branch.status === "created" && result.value.branch.name) { + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.value.branch.name, + worktreePath: selectedThreadWorktreePath, + }, + }); + if (AsyncResult.isFailure(syncResult)) { + return AsyncResult.failure(syncResult.cause); + } + } else { + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + } + return result; + }, + { managedExternally: true }, + ); }, [ + runStackedAction, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, selectedThreadWorktreePath, diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts index 6c855a3ebf7..a8c037db6f7 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.ts +++ b/apps/mobile/src/state/use-selected-thread-git-state.ts @@ -2,9 +2,10 @@ import { useMemo } from "react"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { useBranches } from "./queries"; +import { useEnvironmentQuery } from "./query"; +import { sourceControlEnvironment } from "./sourceControl"; import { useVcsActionState } from "./use-vcs-action-state"; -import { useVcsRefs } from "./use-vcs-refs"; -import { useSourceControlDiscovery } from "./use-source-control-discovery"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; @@ -20,7 +21,14 @@ export function useSelectedThreadGitState() { [selectedThread?.environmentId, selectedThreadCwd], ); const gitActionState = useVcsActionState(selectedThreadGitTarget); - const sourceControlDiscovery = useSourceControlDiscovery(selectedThread?.environmentId ?? null); + const sourceControlDiscovery = useEnvironmentQuery( + selectedThread === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: selectedThread.environmentId, + input: {}, + }), + ); const selectedThreadBranchTarget = useMemo( () => ({ @@ -30,7 +38,7 @@ export function useSelectedThreadGitState() { }), [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot], ); - const selectedThreadBranchState = useVcsRefs(selectedThreadBranchTarget); + const selectedThreadBranchState = useBranches(selectedThreadBranchTarget); const selectedThreadBranches = useMemo( () => dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter( diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 232135b6a7e..c9e9db12530 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,9 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; +import { threadEnvironment } from "../state/threads"; import { scopedRequestKey } from "../lib/scopedEntities"; import { buildPendingUserInputAnswers, @@ -12,11 +13,10 @@ import { setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; -import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; import { useSelectedThreadDetail } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; +import { useAtomCommand } from "./use-atom-command"; const userInputDraftsByRequestKeyAtom = Atom.make< Record> @@ -54,6 +54,14 @@ function setUserInputDraftCustomAnswer( } export function useSelectedThreadRequests() { + const respondToApproval = useAtomCommand( + threadEnvironment.respondToApproval, + "thread approval response", + ); + const respondToUserInput = useAtomCommand( + threadEnvironment.respondToUserInput, + "thread user input response", + ); const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThread = useSelectedThreadDetail(); const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); @@ -112,26 +120,19 @@ export function useSelectedThreadRequests() { return; } - const client = getEnvironmentClient(selectedThreadShell.environmentId); - if (!client) { - return; - } - setRespondingApprovalId(requestId); - try { - await client.orchestration.dispatchCommand({ - type: "thread.approval.respond", - commandId: CommandId.make(uuidv4()), + const result = await respondToApproval({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, requestId, decision, - createdAt: new Date().toISOString(), - }); - } finally { - setRespondingApprovalId((current) => (current === requestId ? null : current)); - } + }, + }); + setRespondingApprovalId((current) => (current === requestId ? null : current)); + return result; }, - [selectedThreadShell], + [respondToApproval, selectedThreadShell], ); const onSubmitUserInput = useCallback(async () => { @@ -139,27 +140,25 @@ export function useSelectedThreadRequests() { return; } - const client = getEnvironmentClient(selectedThreadShell.environmentId); - if (!client) { - return; - } - setRespondingUserInputId(activePendingUserInput.requestId); - try { - await client.orchestration.dispatchCommand({ - type: "thread.user-input.respond", - commandId: CommandId.make(uuidv4()), + const result = await respondToUserInput({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, requestId: activePendingUserInput.requestId, answers: activePendingUserInputAnswers, - createdAt: new Date().toISOString(), - }); - } finally { - setRespondingUserInputId((current) => - current === activePendingUserInput.requestId ? null : current, - ); - } - }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]); + }, + }); + setRespondingUserInputId((current) => + current === activePendingUserInput.requestId ? null : current, + ); + return result; + }, [ + activePendingUserInput, + activePendingUserInputAnswers, + respondToUserInput, + selectedThreadShell, + ]); return { activePendingApproval, diff --git a/apps/mobile/src/state/use-shell-snapshot.ts b/apps/mobile/src/state/use-shell-snapshot.ts deleted file mode 100644 index 56d69db7bfb..00000000000 --- a/apps/mobile/src/state/use-shell-snapshot.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; -import { useAtomValue } from "@effect/atom-react"; -import { Atom } from "effect/unstable/reactivity"; -import { - EMPTY_SHELL_SNAPSHOT_ATOM, - EMPTY_SHELL_SNAPSHOT_STATE, - createShellSnapshotManager, - getShellSnapshotTargetKey, - shellSnapshotStateAtom, - type ShellSnapshotState, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import type { CachedShellSnapshot } from "../lib/storage"; - -const cachedShellSnapshotMetadataAtom = Atom.make< - Readonly> ->({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:cached-shell-snapshot-metadata")); - -export const shellSnapshotManager = createShellSnapshotManager({ - getRegistry: () => appAtomRegistry, -}); - -export function hydrateCachedShellSnapshot(cached: CachedShellSnapshot): void { - shellSnapshotManager.syncSnapshot({ environmentId: cached.environmentId }, cached.snapshot); - appAtomRegistry.set(cachedShellSnapshotMetadataAtom, { - ...appAtomRegistry.get(cachedShellSnapshotMetadataAtom), - [cached.environmentId]: { - snapshotReceivedAt: cached.snapshotReceivedAt, - }, - }); -} - -export function markShellSnapshotLive(environmentId: EnvironmentId): void { - const current = appAtomRegistry.get(cachedShellSnapshotMetadataAtom); - if (current[environmentId] === undefined) { - return; - } - - const next = { ...current }; - delete next[environmentId]; - appAtomRegistry.set(cachedShellSnapshotMetadataAtom, next); -} - -export function clearCachedShellSnapshotMetadata(environmentId: EnvironmentId): void { - markShellSnapshotLive(environmentId); -} - -export function useCachedShellSnapshotMetadata(): Readonly< - Record -> { - return useAtomValue(cachedShellSnapshotMetadataAtom); -} - -export function useShellSnapshot(environmentId: EnvironmentId | null): ShellSnapshotState { - const targetKey = getShellSnapshotTargetKey({ environmentId }); - const state = useAtomValue( - targetKey !== null ? shellSnapshotStateAtom(targetKey) : EMPTY_SHELL_SNAPSHOT_ATOM, - ); - return targetKey === null ? EMPTY_SHELL_SNAPSHOT_STATE : state; -} - -export function useShellSnapshotStates( - environmentIds: ReadonlyArray, -): Readonly> { - const stableEnvironmentIds = useMemo( - () => Arr.sort(new Set(environmentIds), Order.String), - [environmentIds], - ); - const snapshotCacheRef = useRef>>({}); - - const subscribe = useCallback( - (onStoreChange: () => void) => { - const unsubs = stableEnvironmentIds.map((environmentId) => - appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), onStoreChange), - ); - return () => { - for (const unsub of unsubs) { - unsub(); - } - }; - }, - [stableEnvironmentIds], - ); - - const getSnapshot = useCallback(() => { - const previous = snapshotCacheRef.current; - let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; - const next: Record = {}; - - for (const environmentId of stableEnvironmentIds) { - const snapshot = shellSnapshotManager.getSnapshot({ environmentId }); - next[environmentId] = snapshot; - if (!hasChanged && previous[environmentId] !== snapshot) { - hasChanged = true; - } - } - - if (!hasChanged) { - return previous; - } - - snapshotCacheRef.current = next; - return next; - }, [stableEnvironmentIds]); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/apps/mobile/src/state/use-source-control-discovery.ts b/apps/mobile/src/state/use-source-control-discovery.ts deleted file mode 100644 index 8f206be2cee..00000000000 --- a/apps/mobile/src/state/use-source-control-discovery.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, - type SourceControlDiscoveryClient, - type SourceControlDiscoveryState, - type SourceControlDiscoveryTarget, - createSourceControlDiscoveryManager, - getSourceControlDiscoveryTargetKey, - sourceControlDiscoveryStateAtom, -} from "@t3tools/client-runtime"; -import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -function sourceControlDiscoveryTargetForEnvironment( - environmentId: EnvironmentId | null, -): SourceControlDiscoveryTarget { - return { key: environmentId ?? null }; -} - -export function refreshSourceControlDiscoveryForEnvironment( - environmentId: EnvironmentId | null, - client?: SourceControlDiscoveryClient | null, -): Promise { - return sourceControlDiscoveryManager.refresh( - sourceControlDiscoveryTargetForEnvironment(environmentId), - client ?? undefined, - ); -} - -export function invalidateSourceControlDiscoveryForEnvironment( - environmentId: EnvironmentId | null, -): void { - sourceControlDiscoveryManager.invalidate( - sourceControlDiscoveryTargetForEnvironment(environmentId), - ); -} - -export function resetSourceControlDiscoveryState(): void { - sourceControlDiscoveryManager.reset(); -} - -export function resetSourceControlDiscoveryStateForTests(): void { - resetSourceControlDiscoveryState(); -} - -export function useSourceControlDiscovery( - environmentId: EnvironmentId | null, -): SourceControlDiscoveryState { - const target = useMemo( - () => sourceControlDiscoveryTargetForEnvironment(environmentId), - [environmentId], - ); - - useEffect(() => { - return sourceControlDiscoveryManager.watch(target); - }, [target]); - - const targetKey = getSourceControlDiscoveryTargetKey(target); - const state = useAtomValue( - targetKey !== null - ? sourceControlDiscoveryStateAtom(targetKey) - : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, - ); - return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; -} diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index f829dd3600b..328557a2005 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -1,77 +1,82 @@ -import { useAtomValue } from "@effect/atom-react"; import { - createTerminalSessionManager, - EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, - EMPTY_TERMINAL_SESSION_ATOM, - getKnownTerminalSessionTarget, - getKnownTerminalSessionListFilter, - knownTerminalSessionsAtom, - terminalSessionStateAtom, - type TerminalSessionTarget, + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, + EMPTY_TERMINAL_SESSION_STATE, + type KnownTerminalSession, type TerminalSessionState, - type TerminalAttachSessionInput, -} from "@t3tools/client-runtime"; -import type { EnvironmentId, TerminalMetadataStreamEvent } from "@t3tools/contracts"; +} from "@t3tools/client-runtime/state/terminal"; +import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; import { useMemo } from "react"; -import { appAtomRegistry } from "./atom-registry"; +import { useEnvironmentQuery } from "./query"; +import { terminalEnvironment } from "./terminal"; -export const terminalSessionManager = createTerminalSessionManager({ - getRegistry: () => appAtomRegistry, -}); - -export function subscribeTerminalMetadata(input: { - readonly environmentId: EnvironmentId; - readonly client: { - readonly terminal: { - readonly onMetadata: ( - listener: (event: TerminalMetadataStreamEvent) => void, - options?: { readonly onResubscribe?: () => void }, - ) => () => void; - }; - }; -}) { - return terminalSessionManager.subscribeMetadata(input); -} - -export function attachTerminalSession( - input: TerminalAttachSessionInput & { - readonly environmentId: EnvironmentId; - }, -) { - return terminalSessionManager.attach({ - environmentId: input.environmentId, - client: input.client, - terminal: input.terminal, - ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), - ...(input.onEvent ? { onEvent: input.onEvent } : {}), - }); -} - -export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { - const target = getKnownTerminalSessionTarget(input); - return useAtomValue( - target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, +export function useAttachedTerminalSession(input: { + readonly environmentId: EnvironmentId | null; + readonly terminal: TerminalAttachInput | null; +}): TerminalSessionState { + const attach = useEnvironmentQuery( + input.environmentId !== null && input.terminal !== null + ? terminalEnvironment.attach({ + environmentId: input.environmentId, + input: input.terminal, + }) + : null, ); -} - -export function useTerminalSessionTarget(input: TerminalSessionTarget) { - return useMemo( - () => ({ - environmentId: input.environmentId, - threadId: input.threadId, - terminalId: input.terminalId, - }), - [input.environmentId, input.threadId, input.terminalId], + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), ); + + return useMemo(() => { + if (input.environmentId === null || input.terminal === null) { + return EMPTY_TERMINAL_SESSION_STATE; + } + const summary = + metadata.data?.find( + (terminal) => + terminal.threadId === input.terminal?.threadId && + terminal.terminalId === input.terminal?.terminalId, + ) ?? null; + const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); + return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; + }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); } export function useKnownTerminalSessions(input: { - readonly environmentId: TerminalSessionTarget["environmentId"]; - readonly threadId: TerminalSessionTarget["threadId"]; -}) { - const filter = getKnownTerminalSessionListFilter(input); - return useAtomValue( - filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray { + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), ); + return useMemo(() => { + if (input.environmentId === null) { + return []; + } + return (metadata.data ?? []) + .filter((summary) => input.threadId === null || summary.threadId === input.threadId) + .map((summary) => ({ + target: { + environmentId: input.environmentId!, + threadId: ThreadId.make(summary.threadId), + terminalId: summary.terminalId, + }, + state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), + })) + .sort((left, right) => + left.target.terminalId.localeCompare(right.target.terminalId, undefined, { + numeric: true, + }), + ); + }, [input.environmentId, input.threadId, metadata.data]); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 7dfdc4cd57e..90831f8437a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,10 +1,17 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; -import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; -import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; -import { Atom } from "effect/unstable/reactivity"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -14,36 +21,25 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity"; +import { buildThreadFeed } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; import { appendComposerDraftAttachments, appendComposerDraftText, - clearComposerDraft, + clearComposerDraftContent, composerDraftsAtom, ensureComposerDraftsLoaded, + getComposerDraftSnapshot, removeComposerDraftAttachment, setComposerDraftText, + updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; -import { getEnvironmentClient } from "./environment-session-registry"; -import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types"; -import { - setPendingConnectionError, - useRemoteConnectionStatus, -} from "../state/use-remote-environment-registry"; -import { useRemoteCatalog } from "../state/use-remote-catalog"; +import { setPendingConnectionError } from "../state/use-remote-environment-registry"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; - -const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:thread-composer:dispatching-message-id"), -); - -const queuedMessagesByThreadKeyAtom = Atom.make>>( - {}, -).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages")); +import { enqueueThreadOutboxMessage } from "./thread-outbox"; +import { useThreadOutboxMessages } from "./use-thread-outbox"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -76,112 +72,11 @@ export function useThreadDraftForThread(input: { }; } -function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { - appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); -} - -function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { - const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); - appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); -} - -function enqueueQueuedMessage(message: QueuedThreadMessage): void { - const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); - const threadKey = scopedThreadKey(message.environmentId, message.threadId); - appAtomRegistry.set(queuedMessagesByThreadKeyAtom, { - ...current, - [threadKey]: [...(current[threadKey] ?? []), message], - }); -} - -function removeQueuedMessage( - environmentId: EnvironmentId, - threadId: ThreadId, - queuedMessageId: MessageId, -): void { - const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); - const threadKey = scopedThreadKey(environmentId, threadId); - const existing = current[threadKey]; - if (!existing) { - return; - } - - const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId); - const next = { ...current }; - if (nextQueue.length === 0) { - delete next[threadKey]; - } else { - next[threadKey] = nextQueue; - } - - appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next); -} - -function useQueueDrain(input: { - readonly dispatchingQueuedMessageId: MessageId | null; - readonly queuedMessagesByThreadKey: Record>; - readonly threads: ReadonlyArray; - readonly environments: ReadonlyArray; - readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise; -}) { - const { - dispatchingQueuedMessageId, - environments, - queuedMessagesByThreadKey, - sendQueuedMessage, - threads, - } = input; - - useEffect(() => { - if (dispatchingQueuedMessageId !== null) { - return; - } - - for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { - const nextQueuedMessage = queuedMessages[0]; - if (!nextQueuedMessage) { - continue; - } - - const thread = threads.find( - (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey, - ); - if (!thread) { - continue; - } - - const environment = environments.find( - (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, - ); - if (!environment || environment.connectionState !== "ready") { - continue; - } - - const threadStatus = thread.session?.status; - if (threadStatus === "running" || threadStatus === "starting") { - continue; - } - - void sendQueuedMessage(nextQueuedMessage); - return; - } - }, [ - dispatchingQueuedMessageId, - environments, - queuedMessagesByThreadKey, - sendQueuedMessage, - threads, - ]); -} - export function useThreadComposerState() { - const { connectedEnvironments } = useRemoteConnectionStatus(); - const { threads } = useRemoteCatalog(); const { selectedThread: selectedThreadShell } = useThreadSelection(); - const selectedThread = useSelectedThreadDetail(); + const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); - const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); - const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom); + const queuedMessagesByThreadKey = useThreadOutboxMessages(); useEffect(() => { ensureComposerDraftsLoaded(); @@ -194,21 +89,22 @@ export function useThreadComposerState() { () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); - const selectedThreadFeed = useMemo( - () => - selectedThread - ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId) - : [], - [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], + () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []), + [selectedThreadDetail], ); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; + const selectedThread = selectedThreadDetail ?? selectedThreadShell; + const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; + const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; const selectedThreadSessionActivity = useMemo(() => { + const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread?.session) { return null; } @@ -217,10 +113,10 @@ export function useThreadComposerState() { orchestrationStatus: selectedThread.session.status, activeTurnId: selectedThread.session.activeTurnId ?? undefined, }; - }, [selectedThread]); + }, [selectedThreadDetail, selectedThreadShell]); - const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; const activeWorkStartedAt = useMemo(() => { + const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread) { return null; } @@ -228,97 +124,52 @@ export function useThreadComposerState() { return deriveActiveWorkStartedAt( selectedThread.latestTurn, selectedThreadSessionActivity, - queuedSendStartedAt, + null, ); - }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); + }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); const activeThreadBusy = !!selectedThread && (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); - const sendQueuedMessage = useCallback( - async (queuedMessage: QueuedThreadMessage) => { - const client = getEnvironmentClient(queuedMessage.environmentId); - const thread = threads.find( - (candidate) => - candidate.environmentId === queuedMessage.environmentId && - candidate.id === queuedMessage.threadId, - ); - if (!client || !thread) { - return; - } - - beginDispatchingQueuedMessage(queuedMessage.messageId); - try { - await client.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: queuedMessage.commandId, - threadId: queuedMessage.threadId, - message: { - messageId: queuedMessage.messageId, - role: "user", - text: queuedMessage.text, - attachments: queuedMessage.attachments, - }, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - createdAt: queuedMessage.createdAt, - }); - - removeQueuedMessage( - queuedMessage.environmentId, - queuedMessage.threadId, - queuedMessage.messageId, - ); - } catch (error) { - removeQueuedMessage( - queuedMessage.environmentId, - queuedMessage.threadId, - queuedMessage.messageId, - ); - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to send message.", - ); - } finally { - finishDispatchingQueuedMessage(queuedMessage.messageId); - } - }, - [threads], - ); - - useQueueDrain({ - dispatchingQueuedMessageId, - queuedMessagesByThreadKey, - threads, - environments: connectedEnvironments, - sendQueuedMessage, - }); - - const onSendMessage = useCallback(() => { + const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { - return; + return null; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const draft = composerDrafts[threadKey]; - const text = (draft?.text ?? "").trim(); - const attachments = draft?.attachments ?? []; + const draft = getComposerDraftSnapshot(threadKey); + const thread = selectedThreadDetail ?? selectedThreadShell; + const text = draft.text.trim(); + const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { - return; + return null; } const metadata = makeQueuedMessageMetadata(); - enqueueQueuedMessage({ - environmentId: selectedThreadShell.environmentId, - threadId: selectedThreadShell.id, - messageId: MessageId.make(metadata.messageId), - commandId: CommandId.make(metadata.commandId), - text, - attachments, - createdAt: metadata.createdAt, - }); - clearComposerDraft(threadKey); - }, [composerDrafts, selectedThreadShell]); + const messageId = MessageId.make(metadata.messageId); + try { + await enqueueThreadOutboxMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId, + commandId: CommandId.make(metadata.commandId), + text, + attachments, + modelSelection: draft.modelSelection ?? thread.modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: draft.interactionMode ?? thread.interactionMode, + createdAt: metadata.createdAt, + }); + clearComposerDraftContent(threadKey); + return messageId; + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to save the queued message.", + ); + return null; + } + }, [selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { @@ -385,7 +236,12 @@ export function useThreadComposerState() { appendComposerDraftAttachments(threadKey, images); } } catch (error) { - console.error("[native paste] error converting images", error); + console.error("[native paste] error converting images", { + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + uriCount: uris.length, + ...safeErrorLogAttributes(error), + }); } }, [composerDrafts, selectedThreadShell], @@ -403,12 +259,45 @@ export function useThreadComposerState() { [selectedThreadShell], ); + const onUpdateModelSelection = useCallback( + (value: ModelSelection) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { modelSelection: value }); + }, + [selectedThreadKey], + ); + + const onUpdateRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + }, + [selectedThreadKey], + ); + + const onUpdateInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { interactionMode: value }); + }, + [selectedThreadKey], + ); + return { selectedThreadFeed, selectedThreadQueueCount, activeWorkStartedAt, draftMessage, draftAttachments, + modelSelection, + runtimeMode, + interactionMode, activeThreadBusy, onChangeDraftMessage, onPickDraftImages, @@ -416,5 +305,8 @@ export function useThreadComposerState() { onNativePasteImages, onRemoveDraftImage, onSendMessage, + onUpdateModelSelection, + onUpdateRuntimeMode, + onUpdateInteractionMode, }; } diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 900dbd648b5..388b4d9afcb 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -1,82 +1,26 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_THREAD_DETAIL_ATOM, - EMPTY_THREAD_DETAIL_STATE, - createThreadDetailManager, - getThreadDetailTargetKey, - threadDetailStateAtom, - type ThreadDetailState, - type ThreadDetailTarget, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; -import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useEnvironmentThread } from "./threads"; import { useThreadSelection } from "./use-thread-selection"; -function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { - const thread = state.data; - if (!thread || state.isDeleted) { - return false; - } - - if (thread.latestTurn?.sourceProposedPlan) { - return true; - } - - const sessionStatus = thread.session?.status; - if (sessionStatus && sessionStatus !== "idle" && sessionStatus !== "stopped") { - return true; - } - - return ( - derivePendingApprovals(thread.activities).length > 0 || - derivePendingUserInputs(thread.activities).length > 0 - ); +export interface ThreadDetailTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; } -const threadDetailManager = createThreadDetailManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.orchestration : null; - }, - getClientIdentity: (environmentId) => { - return getEnvironmentClient(environmentId) ? environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - retention: { - idleTtlMs: 5 * 60 * 1_000, - maxRetainedEntries: 24, - shouldKeepWarm: (_target, state) => shouldKeepThreadDetailWarm(state), - }, -}); - -export function useThreadDetail(target: ThreadDetailTarget): ThreadDetailState { - const { environmentId, threadId } = target; - const targetKey = getThreadDetailTargetKey(target); - - useEffect( - () => threadDetailManager.watch({ environmentId, threadId }), - [environmentId, threadId], - ); - - const state = useAtomValue( - targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM, - ); - return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state; +export function useThreadDetail(target: ThreadDetailTarget) { + return useEnvironmentThread(target.environmentId, target.threadId); } -export function useSelectedThreadDetail() { +export function useSelectedThreadDetailState() { const { selectedThread } = useThreadSelection(); - const state = useThreadDetail({ + return useThreadDetail({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); +} - return useMemo(() => state.data, [state.data]); +export function useSelectedThreadDetail() { + return Option.getOrNull(useSelectedThreadDetailState().data); } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts new file mode 100644 index 00000000000..e912d6366b4 --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -0,0 +1,293 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { CommandId, type MessageId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { scopedThreadKey } from "../lib/scopedEntities"; +import { appAtomRegistry } from "./atom-registry"; +import { useThreadShells } from "./entities"; +import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; +import { + modelSelectionsEqual, + resolveThreadOutboxDeliveryAction, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, + threadOutboxRetryDelayMs, + type QueuedThreadMessage, + type ThreadOutboxCommandStage, +} from "./thread-outbox-model"; +import { threadEnvironment } from "./threads"; +import { useAtomCommand } from "./use-atom-command"; +import { useThreadOutboxMessages, useThreadOutboxShellStatuses } from "./use-thread-outbox"; +import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; + +export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:thread-outbox:dispatching-message-id"), +); + +function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { + appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); +} + +function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { + const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); + appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); +} + +function findThread( + threads: ReadonlyArray, + message: QueuedThreadMessage, +): EnvironmentThreadShell | undefined { + return threads.find( + (candidate) => + candidate.environmentId === message.environmentId && candidate.id === message.threadId, + ); +} + +function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { + return CommandId.make(`${message.commandId}:${setting}`); +} + +export function useThreadOutboxDrain(): void { + const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + reportFailure: false, + }); + const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); + const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const shellStatuses = useThreadOutboxShellStatuses(); + const threads = useThreadShells(); + const { connectedEnvironments } = useRemoteConnectionStatus(); + const [retryTick, setRetryTick] = useState(0); + const retryAttemptRef = useRef(new Map()); + const retryNotBeforeRef = useRef(new Map()); + const retryTimersRef = useRef(new Map>()); + + useEffect(() => { + ensureThreadOutboxLoaded(); + return () => { + for (const timer of retryTimersRef.current.values()) { + clearTimeout(timer); + } + retryTimersRef.current.clear(); + }; + }, []); + + const sendQueuedMessage = useCallback( + async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { + const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const reportFailure = ( + commandResult: AtomCommandResult, + stage: ThreadOutboxCommandStage, + ): boolean => { + if (!AsyncResult.isFailure(commandResult)) { + return false; + } + const action = resolveThreadOutboxFailureAction({ + stage, + error: Cause.squash(commandResult.cause), + interrupted: Cause.hasInterruptsOnly(commandResult.cause), + }); + const retry = action === "retry"; + console.warn("[thread-outbox] queued message delivery failed", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + stage, + cause: commandResult.cause, + retry, + }); + return retry; + }; + const completeDelivery = async ( + deliveryResult: AtomCommandResult, + ): Promise => { + if (reportFailure(deliveryResult, "start-turn")) { + return false; + } + + try { + await removeThreadOutboxMessage(queuedMessage); + return true; + } catch (error) { + console.warn("[thread-outbox] failed to remove delivered queued message", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + error, + }); + return false; + } + }; + + if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { + const updateResult = await updateThreadMetadata({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "model-selection"), + threadId: queuedMessage.threadId, + modelSelection: settings.modelSelection, + }, + }); + if (AsyncResult.isFailure(updateResult)) { + reportFailure(updateResult, "settings-sync"); + return false; + } + } + + if (settings.runtimeMode !== thread.runtimeMode) { + const runtimeResult = await setThreadRuntimeMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "runtime-mode"), + threadId: queuedMessage.threadId, + runtimeMode: settings.runtimeMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(runtimeResult)) { + reportFailure(runtimeResult, "settings-sync"); + return false; + } + } + + if (settings.interactionMode !== thread.interactionMode) { + const interactionResult = await setThreadInteractionMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "interaction-mode"), + threadId: queuedMessage.threadId, + interactionMode: settings.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(interactionResult)) { + reportFailure(interactionResult, "settings-sync"); + return false; + } + } + + const deliveryResult = await startTurn({ + environmentId: queuedMessage.environmentId, + input: { + commandId: queuedMessage.commandId, + threadId: queuedMessage.threadId, + message: { + messageId: queuedMessage.messageId, + role: "user", + text: queuedMessage.text, + attachments: queuedMessage.attachments, + }, + modelSelection: settings.modelSelection, + runtimeMode: settings.runtimeMode, + interactionMode: settings.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + return completeDelivery(deliveryResult); + }, + [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata], + ); + + useEffect(() => { + if (dispatchingQueuedMessageId !== null) { + return; + } + + for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { + const nextQueuedMessage = queuedMessages[0]; + if (!nextQueuedMessage) { + continue; + } + if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { + continue; + } + + const thread = findThread(threads, nextQueuedMessage); + if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) { + continue; + } + + const environment = connectedEnvironments.find( + (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, + ); + const deliveryAction = resolveThreadOutboxDeliveryAction({ + threadExists: thread !== undefined, + shellStatus: shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty", + environmentConnected: environment?.connectionState === "connected", + threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", + }); + if (deliveryAction === "wait") { + continue; + } + + beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + const delivery = + deliveryAction === "remove" + ? removeThreadOutboxMessage(nextQueuedMessage).then( + () => true, + (error) => { + console.warn("[thread-outbox] failed to remove message for a missing thread", { + environmentId: nextQueuedMessage.environmentId, + threadId: nextQueuedMessage.threadId, + messageId: nextQueuedMessage.messageId, + error, + }); + return false; + }, + ) + : thread !== undefined + ? sendQueuedMessage(nextQueuedMessage, thread) + : Promise.resolve(false); + void delivery + .then((sent) => { + if (sent) { + retryAttemptRef.current.delete(nextQueuedMessage.messageId); + retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); + const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + retryTimersRef.current.delete(nextQueuedMessage.messageId); + } + return; + } + + const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1; + retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt); + const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); + retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs); + const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + } + const retryTimer = setTimeout(() => { + retryTimersRef.current.delete(nextQueuedMessage.messageId); + setRetryTick((current) => current + 1); + }, retryDelayMs); + retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer); + }) + .finally(() => { + finishDispatchingQueuedMessage(nextQueuedMessage.messageId); + }); + return; + } + }, [ + connectedEnvironments, + dispatchingQueuedMessageId, + queuedMessagesByThreadKey, + retryTick, + sendQueuedMessage, + shellStatuses, + threads, + ]); +} diff --git a/apps/mobile/src/state/use-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts new file mode 100644 index 00000000000..fb090cd0886 --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox.ts @@ -0,0 +1,28 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentShell } from "./shell"; +import { threadOutboxManager } from "./thread-outbox"; + +const threadOutboxShellStatusesAtom = Atom.make( + (get): ReadonlyMap => { + const statuses = new Map(); + for (const queue of Object.values(get(threadOutboxManager.queuedMessagesByThreadKeyAtom))) { + const environmentId = queue[0]?.environmentId; + if (environmentId !== undefined && !statuses.has(environmentId)) { + statuses.set(environmentId, get(environmentShell.stateValueAtom(environmentId)).status); + } + } + return statuses; + }, +).pipe(Atom.withLabel("mobile:thread-outbox:shell-statuses")); + +export function useThreadOutboxMessages() { + return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom); +} + +export function useThreadOutboxShellStatuses() { + return useAtomValue(threadOutboxShellStatusesAtom); +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index c303faed617..06175b6d237 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,11 +1,12 @@ import { useLocalSearchParams } from "expo-router"; import { useMemo } from "react"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts"; -import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; -import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime"; -import { useRemoteCatalog } from "./use-remote-catalog"; -import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; +import { useProject, useThreadShell } from "../state/entities"; +import { + useRemoteEnvironmentRuntime, + useSavedRemoteConnection, +} from "./use-remote-environment-registry"; function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -15,43 +16,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { return value ?? null; } -function deriveSelectedThread( - selectedThreadRef: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null, - threads: ReadonlyArray, -): EnvironmentScopedThreadShell | null { - if (!selectedThreadRef) { - return null; - } - - return ( - threads.find( - (thread) => - thread.environmentId === selectedThreadRef.environmentId && - thread.id === selectedThreadRef.threadId, - ) ?? null - ); -} - -function deriveSelectedThreadProject( - selectedThread: EnvironmentScopedThreadShell | null, - projects: ReadonlyArray, -): EnvironmentScopedProjectShell | null { - if (!selectedThread) { - return null; - } - - return ( - projects.find( - (project) => - project.environmentId === selectedThread.environmentId && - project.id === selectedThread.projectId, - ) ?? null - ); -} - export function useThreadSelection() { - const { projects, threads } = useRemoteCatalog(); - const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); const params = useLocalSearchParams<{ environmentId?: string | string[]; threadId?: string | string[]; @@ -68,22 +33,21 @@ export function useThreadSelection() { threadId: ThreadId.make(threadId), }; }, [params.environmentId, params.threadId]); - const selectedThread = useMemo( - () => deriveSelectedThread(selectedThreadRef, threads), - [selectedThreadRef, threads], + const selectedThread = useThreadShell(selectedThreadRef); + const selectedProjectRef = useMemo( + () => + selectedThread === null + ? null + : { + environmentId: selectedThread.environmentId, + projectId: selectedThread.projectId, + }, + [selectedThread], ); - - const selectedThreadProject = useMemo( - () => deriveSelectedThreadProject(selectedThread, projects), - [projects, selectedThread], - ); - - const selectedEnvironmentConnection = selectedThread - ? (savedConnectionsById[selectedThread.environmentId] ?? null) - : null; - const selectedEnvironmentRuntime = selectedThread - ? (environmentStateById[selectedThread.environmentId] ?? null) - : null; + const selectedThreadProject = useProject(selectedProjectRef); + const selectedEnvironmentId = selectedThread?.environmentId ?? null; + const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); + const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); return { selectedThreadRef, diff --git a/apps/mobile/src/state/use-vcs-action-state.ts b/apps/mobile/src/state/use-vcs-action-state.ts index 64e4da958ef..e169005a07f 100644 --- a/apps/mobile/src/state/use-vcs-action-state.ts +++ b/apps/mobile/src/state/use-vcs-action-state.ts @@ -1,40 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; -import { - type VcsActionState, - type VcsActionTarget, - EMPTY_VCS_ACTION_ATOM, - EMPTY_VCS_ACTION_STATE, - createVcsActionManager, - getVcsActionTargetKey, - vcsActionStateAtom, -} from "@t3tools/client-runtime"; +import { type VcsActionState, type VcsActionTarget } from "@t3tools/client-runtime/state/vcs"; +import { Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; - -export const vcsActionManager = createVcsActionManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; - }, - getActionId: uuidv4, -}); +import { vcsActionManager } from "./vcs"; export function useVcsActionState(target: VcsActionTarget): VcsActionState { - const targetKey = getVcsActionTargetKey(target); - const state = useAtomValue( - targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, - ); - return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; + return useAtomValue(vcsActionManager.stateAtom(target)); } -// --------------------------------------------------------------------------- -// Git action result notification -// --------------------------------------------------------------------------- - export interface GitActionResultNotification { readonly type: "success" | "error"; readonly title: string; @@ -44,26 +19,28 @@ export interface GitActionResultNotification { const RESULT_DISMISS_MS = 5_000; -type ResultListener = (result: GitActionResultNotification | null) => void; -const resultListeners = new Set(); -let currentResult: GitActionResultNotification | null = null; +const gitActionResultAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:git-action-result"), +); let dismissTimer: ReturnType | null = null; function broadcast(result: GitActionResultNotification | null): void { - currentResult = result; - for (const listener of resultListeners) { - listener(result); - } + appAtomRegistry.set(gitActionResultAtom, result); } export function showGitActionResult(result: GitActionResultNotification): void { if (dismissTimer) clearTimeout(dismissTimer); broadcast(result); - dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS); + dismissTimer = setTimeout(() => { + dismissTimer = null; + broadcast(null); + }, RESULT_DISMISS_MS); } export function dismissGitActionResult(): void { if (dismissTimer) clearTimeout(dismissTimer); + dismissTimer = null; broadcast(null); } @@ -71,23 +48,10 @@ export function useGitActionResultNotification(): { readonly result: GitActionResultNotification | null; readonly dismiss: () => void; } { - const [result, setResult] = useState(currentResult); - - useEffect(() => { - resultListeners.add(setResult); - setResult(currentResult); - return () => { - resultListeners.delete(setResult); - }; - }, []); - + const result = useAtomValue(gitActionResultAtom); return { result, dismiss: dismissGitActionResult }; } -// --------------------------------------------------------------------------- -// Unified git action progress (combines running state + result notification) -// --------------------------------------------------------------------------- - export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; export interface GitActionProgress { diff --git a/apps/mobile/src/state/use-vcs-refs.ts b/apps/mobile/src/state/use-vcs-refs.ts deleted file mode 100644 index 3af3a6e945e..00000000000 --- a/apps/mobile/src/state/use-vcs-refs.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useMemo } from "react"; -import { - type VcsRefState, - type VcsRefTarget, - EMPTY_VCS_REF_ATOM, - EMPTY_VCS_REF_STATE, - createVcsRefManager, - getVcsRefTargetKey, - vcsRefStateAtom, -} from "@t3tools/client-runtime"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const VCS_REF_LIST_LIMIT = 100; -const VCS_REF_STALE_TIME_MS = 5_000; - -export const vcsRefManager = createVcsRefManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.vcs : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - watchLimit: VCS_REF_LIST_LIMIT, - staleTimeMs: VCS_REF_STALE_TIME_MS, - onBackgroundError: (error) => { - console.warn("[vcs-refs] background refresh failed", error); - }, -}); - -export function useVcsRefs(target: VcsRefTarget): VcsRefState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: target.query ?? null, - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getVcsRefTargetKey(stableTarget); - - useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); - return targetKey === null ? EMPTY_VCS_REF_STATE : state; -} diff --git a/apps/mobile/src/state/use-vcs-status.ts b/apps/mobile/src/state/use-vcs-status.ts deleted file mode 100644 index e7d7049d332..00000000000 --- a/apps/mobile/src/state/use-vcs-status.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsStatusState, - type VcsStatusTarget, - EMPTY_VCS_STATUS_ATOM, - EMPTY_VCS_STATUS_STATE, - createVcsStatusManager, - getVcsStatusTargetKey, - vcsStatusStateAtom, -} from "@t3tools/client-runtime"; -import { useEffect } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -/** - * Singleton VCS status manager for the mobile app. - * - * Uses ref-counted `onStatus` subscriptions (one per unique cwd) - * rather than one-shot `refreshStatus` RPCs. Multiple threads - * sharing the same cwd (i.e. same project, no worktree) share - * a single WS subscription. - * - * `subscribeClientChanges` ensures subscriptions are established - * even when the WS connection isn't ready at mount time, and - * re-established on reconnection. - */ -export const vcsStatusManager = createVcsStatusManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.vcs : null; - }, - getClientIdentity: (environmentId) => { - return getEnvironmentClient(environmentId) ? environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -/** - * Subscribe to live VCS status for a target (environmentId + cwd). - * - * Mirrors the web's `useVcsStatus` hook. Automatically subscribes - * on mount, ref-counts shared cwds, and unsubscribes on unmount. - * Returns reactive `VcsStatusState` via Effect atoms. - */ -export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - - useEffect( - () => vcsStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }), - [target.environmentId, target.cwd], - ); - - const state = useAtomValue( - targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, - ); - return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; -} diff --git a/apps/mobile/src/state/vcs.ts b/apps/mobile/src/state/vcs.ts new file mode 100644 index 00000000000..dc8c251149f --- /dev/null +++ b/apps/mobile/src/state/vcs.ts @@ -0,0 +1,9 @@ +import { + createVcsActionManager, + createVcsEnvironmentAtoms, +} from "@t3tools/client-runtime/state/vcs"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); +export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); diff --git a/apps/mobile/src/state/workspace.ts b/apps/mobile/src/state/workspace.ts new file mode 100644 index 00000000000..368cd0bc468 --- /dev/null +++ b/apps/mobile/src/state/workspace.ts @@ -0,0 +1,30 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useMemo } from "react"; + +import { environmentShellSummaryAtom } from "./shell"; +import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; +import { useEnvironments } from "./environments"; + +export function useWorkspaceState() { + const { isReady, networkStatus, environments } = useEnvironments(); + const shellSummary = useAtomValue(environmentShellSummaryAtom); + const projectedEnvironments = useMemo( + () => environments.map(projectWorkspaceEnvironment), + [environments], + ); + const state = useMemo( + () => + projectWorkspaceState({ + isReady, + networkStatus, + environments: projectedEnvironments, + shellSummary, + }), + [isReady, networkStatus, projectedEnvironments, shellSummary], + ); + + return { + environments: projectedEnvironments, + state, + }; +} diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts new file mode 100644 index 00000000000..e51273d57de --- /dev/null +++ b/apps/mobile/src/state/workspaceModel.test.ts @@ -0,0 +1,123 @@ +import type { EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; +import { + BearerConnectionProfile, + BearerConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; +import type { EnvironmentPresentation } from "./environments"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +function environment( + phase: EnvironmentPresentation["connection"]["phase"], +): EnvironmentPresentation { + const connectionId = `bearer:${ENVIRONMENT_ID}`; + return { + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + displayUrl: "https://environment.example.test", + relayManaged: false, + entry: { + target: new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + connectionId, + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + }), + ), + }, + connection: { + phase, + error: phase === "error" ? "Connection failed." : null, + traceId: phase === "error" ? "trace-1" : null, + }, + serverConfig: null, + }; +} + +const EMPTY_SHELL_SUMMARY: EnvironmentShellSummary = { + hasSnapshot: false, + hasSynchronizingShell: false, + hasCachedShell: false, + hasLiveShell: false, + firstError: null, + latestSnapshotUpdatedAt: null, +}; + +const CACHED_SHELL_SUMMARY: EnvironmentShellSummary = { + ...EMPTY_SHELL_SUMMARY, + hasSnapshot: true, + hasSynchronizingShell: true, + hasCachedShell: true, + latestSnapshotUpdatedAt: "2026-06-07T00:00:00.000Z", +}; + +describe("mobile workspace projection", () => { + it("preserves explicit offline state without presenting it as a connection error", () => { + const projected = projectWorkspaceEnvironment(environment("offline")); + + expect(projected.connectionState).toBe("offline"); + expect(projected.connectionError).toBeNull(); + }); + + it("reports offline before stale connected presentations", () => { + const environments = [projectWorkspaceEnvironment(environment("connected"))]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "offline", + environments, + shellSummary: EMPTY_SHELL_SUMMARY, + }); + + expect(state.connectionState).toBe("offline"); + expect(state.networkStatus).toBe("offline"); + expect(state.hasReadyEnvironment).toBe(false); + }); + + it("projects reconnecting environments dynamically from active phases", () => { + const environments = [ + projectWorkspaceEnvironment(environment("reconnecting")), + projectWorkspaceEnvironment({ + ...environment("connected"), + environmentId: EnvironmentId.make("environment-2"), + }), + ]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "online", + environments, + shellSummary: EMPTY_SHELL_SUMMARY, + }); + + expect(state.connectingEnvironments).toHaveLength(1); + expect(state.connectingEnvironments[0]?.connectionState).toBe("reconnecting"); + expect(state.hasConnectingEnvironment).toBe(true); + expect(state.hasReadyEnvironment).toBe(true); + }); + + it("keeps retained snapshots visible while reconnecting without claiming readiness", () => { + const environments = [projectWorkspaceEnvironment(environment("reconnecting"))]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "online", + environments, + shellSummary: CACHED_SHELL_SUMMARY, + }); + + expect(state.hasLoadedShellSnapshot).toBe(true); + expect(state.hasPendingShellSnapshot).toBe(true); + expect(state.hasReadyEnvironment).toBe(false); + expect(state.connectionState).toBe("reconnecting"); + }); +}); diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts new file mode 100644 index 00000000000..44c43d6c880 --- /dev/null +++ b/apps/mobile/src/state/workspaceModel.ts @@ -0,0 +1,107 @@ +import { type EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; +import { type NetworkStatus } from "@t3tools/client-runtime/connection"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; + +import type { EnvironmentPresentation } from "./environments"; + +export interface WorkspaceEnvironment { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly displayUrl: string; + readonly isRelayManaged: boolean; + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; +} + +export interface WorkspaceState { + readonly isLoadingConnections: boolean; + readonly hasConnections: boolean; + readonly hasLoadedShellSnapshot: boolean; + readonly hasPendingShellSnapshot: boolean; + readonly hasReadyEnvironment: boolean; + readonly hasConnectingEnvironment: boolean; + readonly connectingEnvironments: ReadonlyArray; + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly shellSnapshotError: string | null; + readonly latestCachedSnapshotReceivedAt: string | null; + readonly networkStatus: NetworkStatus; +} + +export function projectWorkspaceEnvironment( + environment: EnvironmentPresentation, +): WorkspaceEnvironment { + return { + environmentId: environment.environmentId, + environmentLabel: environment.label, + displayUrl: environment.displayUrl ?? "", + isRelayManaged: environment.relayManaged, + connectionState: environment.connection.phase, + connectionError: environment.connection.error, + connectionErrorTraceId: environment.connection.traceId, + }; +} + +function overallConnectionState( + environments: ReadonlyArray, + networkStatus: NetworkStatus, +): EnvironmentConnectionPhase { + if (environments.length === 0) { + return "available"; + } + if (networkStatus === "offline") { + return "offline"; + } + if (environments.some((environment) => environment.connectionState === "connected")) { + return "connected"; + } + if (environments.some((environment) => environment.connectionState === "reconnecting")) { + return "reconnecting"; + } + if (environments.some((environment) => environment.connectionState === "connecting")) { + return "connecting"; + } + if (environments.some((environment) => environment.connectionState === "error")) { + return "error"; + } + if (environments.some((environment) => environment.connectionState === "offline")) { + return "offline"; + } + return "available"; +} + +export function projectWorkspaceState(input: { + readonly isReady: boolean; + readonly networkStatus: NetworkStatus; + readonly environments: ReadonlyArray; + readonly shellSummary: EnvironmentShellSummary; +}): WorkspaceState { + const connectingEnvironments = input.environments.filter( + (environment) => + environment.connectionState === "connecting" || + environment.connectionState === "reconnecting", + ); + + return { + isLoadingConnections: !input.isReady, + hasConnections: input.environments.length > 0, + hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, + hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, + hasReadyEnvironment: + input.networkStatus !== "offline" && + input.environments.some((environment) => environment.connectionState === "connected"), + hasConnectingEnvironment: connectingEnvironments.length > 0, + connectingEnvironments, + connectionState: overallConnectionState(input.environments, input.networkStatus), + connectionError: + input.environments.find((environment) => environment.connectionError !== null) + ?.connectionError ?? null, + shellSnapshotError: input.shellSummary.firstError, + latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt, + networkStatus: input.networkStatus, + }; +} + +export type ServerConfigByEnvironmentId = ReadonlyMap; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 404dbbd016b..a8630412d44 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import { execFileSync } from "node:child_process"; +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -22,8 +22,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; -import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; +import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; @@ -48,7 +47,7 @@ import { import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; -import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; @@ -74,7 +73,7 @@ import { } from "./TestProviderAdapter.integration.ts"; import { deriveServerPaths, ServerConfig } from "../src/config.ts"; import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; +import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; @@ -84,7 +83,7 @@ import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); function runGit(cwd: string, args: ReadonlyArray) { - return execFileSync("git", args, { + return NodeChildProcess.execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", @@ -181,7 +180,7 @@ export interface OrchestrationIntegrationHarness { readonly engine: OrchestrationEngineShape; readonly snapshotQuery: ProjectionSnapshotQuery["Service"]; readonly providerService: ProviderService["Service"]; - readonly checkpointStore: CheckpointStore["Service"]; + readonly checkpointStore: CheckpointStore.CheckpointStore["Service"]; readonly checkpointRepository: ProjectionCheckpointRepository["Service"]; readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"]; readonly waitForThread: ( @@ -264,7 +263,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(persistenceLayer), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( @@ -301,9 +300,9 @@ export const makeOrchestrationIntegrationHarness = ( ); const providerRegistryLayer = makeProviderRegistryLayer(); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); + const checkpointStoreLayer = CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive.pipe( - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(persistenceLayer), ); const runtimeServicesLayer = Layer.mergeAll( @@ -357,12 +356,12 @@ export const makeOrchestrationIntegrationHarness = ( ), Layer.provideMerge( WorkspaceEntries.layer.pipe( - Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer), Layer.provide(NodeServices.layer), ), ), - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( @@ -413,7 +412,7 @@ export const makeOrchestrationIntegrationHarness = ( runtime.runPromise(Effect.service(ProviderService)), ).pipe(Effect.orDie); const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => - runtime.runPromise(Effect.service(CheckpointStore)), + runtime.runPromise(Effect.service(CheckpointStore.CheckpointStore)), ).pipe(Effect.orDie); const checkpointRepository = yield* tryRuntimePromise( "load ProjectionCheckpointRepository service", diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index e79897c740e..ccfb9c46742 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import { ApprovalRequestId, @@ -409,7 +409,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v2\n", "utf8"); }), }); @@ -456,7 +456,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v3\n", "utf8"); }), }); @@ -752,7 +752,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v2\n", "utf8"); }), }); yield* startTurn({ @@ -811,7 +811,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v3\n", "utf8"); }), }); yield* startTurn({ @@ -869,7 +869,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ), true, ); - assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); + assert.equal( + NodeFS.readFileSync(NodePath.join(harness.workspaceDir, "README.md"), "utf8"), + "v2\n", + ); assert.equal( gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), false, @@ -1332,7 +1335,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v2\n", "utf8"); }), }); @@ -1390,7 +1393,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v3\n", "utf8"); }), }); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 57e93c5acdd..e703af4b1f4 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -25,7 +25,7 @@ import { import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; import { makeTestProviderAdapterHarness, @@ -63,7 +63,7 @@ const makeIntegrationFixture = Effect.gen(function* () { }); const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); const shared = Layer.mergeAll( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 2b5da74eef0..0d89775844d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node // @effect-diagnostics nodeBuiltinImport:off -import { appendFileSync } from "node:fs"; +import * as NodeFS from "node:fs"; import * as Effect from "effect/Effect"; @@ -42,7 +42,7 @@ function logExit(reason: string): void { if (!exitLogPath) { return; } - appendFileSync(exitLogPath, `${reason}\n`, "utf8"); + NodeFS.appendFileSync(exitLogPath, `${reason}\n`, "utf8"); } process.once("SIGTERM", () => { @@ -693,7 +693,7 @@ const program = Effect.gen(function* () { } const payload = event.payload; return Effect.sync(() => { - appendFileSync( + NodeFS.appendFileSync( requestLogPath, payload.endsWith("\n") ? payload : `${payload}\n`, "utf8", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index a158eaa068d..00b6c4cfcce 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; @@ -20,6 +19,14 @@ import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import serverPackageJson from "../package.json" with { type: "json" }; +import { + ServerCliBuildAssetMissingError, + ServerCliCommandExitError, + ServerCliDevelopmentIconSourceMissingError, + ServerCliDevelopmentIconTargetMissingError, + ServerCliPublishIconSourceMissingError, + ServerCliPublishIconTargetMissingError, +} from "./cliErrors.ts"; interface PackageJson { name: string; @@ -47,11 +54,6 @@ const WorkspaceConfig = Schema.Struct({ type WorkspaceConfig = typeof WorkspaceConfig.Type; const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); -class CliError extends Data.TaggedError("CliError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), ); @@ -64,14 +66,17 @@ const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { return yield* decodeWorkspaceConfig(workspaceYaml); }); -const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Command) { +const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn(command); const exitCode = yield* child.exitCode; if (exitCode !== 0) { - return yield* new CliError({ - message: `Command exited with non-zero exit code (${exitCode})`, + return yield* new ServerCliCommandExitError({ + command: command.command, + args: command.args, + cwd: command.options.cwd, + exitCode, }); } }); @@ -95,14 +100,10 @@ const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(functio const backupPath = `${targetPath}.publish-bak`; if (!(yield* fs.exists(sourcePath))) { - return yield* new CliError({ - message: `Missing publish icon source: ${sourcePath}`, - }); + return yield* new ServerCliPublishIconSourceMissingError({ sourcePath }); } if (!(yield* fs.exists(targetPath))) { - return yield* new CliError({ - message: `Missing publish icon target: ${targetPath}. Run the build subcommand first.`, - }); + return yield* new ServerCliPublishIconTargetMissingError({ targetPath }); } yield* fs.copyFile(targetPath, backupPath); @@ -138,14 +139,10 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides") const targetPath = path.join(serverDir, override.targetRelativePath); if (!(yield* fs.exists(sourcePath))) { - return yield* new CliError({ - message: `Missing development icon source: ${sourcePath}`, - }); + return yield* new ServerCliDevelopmentIconSourceMissingError({ sourcePath }); } if (!(yield* fs.exists(targetPath))) { - return yield* new CliError({ - message: `Missing development icon target: ${targetPath}. Build web first.`, - }); + return yield* new ServerCliDevelopmentIconTargetMissingError({ targetPath }); } yield* fs.copyFile(sourcePath, targetPath); @@ -245,9 +242,7 @@ const publishCmd = Command.make( for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { - return yield* new CliError({ - message: `Missing build asset: ${abs}. Run the build subcommand first.`, - }); + return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); } } diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts new file mode 100644 index 00000000000..91754290db9 --- /dev/null +++ b/apps/server/scripts/cliErrors.test.ts @@ -0,0 +1,31 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts"; + +describe("server CLI errors", () => { + it("preserves failed command context without changing its message", () => { + const error = new ServerCliCommandExitError({ + command: "vp", + args: ["pm", "publish"], + cwd: "/repo", + exitCode: 17, + }); + + assert.equal(error._tag, "ServerCliCommandExitError"); + assert.equal(error.command, "vp"); + assert.deepEqual(error.args, ["pm", "publish"]); + assert.equal(error.cwd, "/repo"); + assert.equal(error.exitCode, 17); + assert.equal(error.message, "Command exited with non-zero exit code (17)"); + }); + + it("preserves a representative missing asset path", () => { + const error = new ServerCliBuildAssetMissingError({ assetPath: "/repo/server.mjs" }); + + assert.equal(error.assetPath, "/repo/server.mjs"); + assert.equal( + error.message, + "Missing build asset: /repo/server.mjs. Run the build subcommand first.", + ); + }); +}); diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts new file mode 100644 index 00000000000..d384c745f29 --- /dev/null +++ b/apps/server/scripts/cliErrors.ts @@ -0,0 +1,70 @@ +import * as Schema from "effect/Schema"; + +export class ServerCliCommandExitError extends Schema.TaggedErrorClass()( + "ServerCliCommandExitError", + { + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.optional(Schema.String), + exitCode: Schema.Int, + }, +) { + override get message(): string { + return `Command exited with non-zero exit code (${this.exitCode})`; + } +} + +export class ServerCliPublishIconSourceMissingError extends Schema.TaggedErrorClass()( + "ServerCliPublishIconSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing publish icon source: ${this.sourcePath}`; + } +} + +export class ServerCliPublishIconTargetMissingError extends Schema.TaggedErrorClass()( + "ServerCliPublishIconTargetMissingError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing publish icon target: ${this.targetPath}. Run the build subcommand first.`; + } +} + +export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedErrorClass()( + "ServerCliDevelopmentIconSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing development icon source: ${this.sourcePath}`; + } +} + +export class ServerCliDevelopmentIconTargetMissingError extends Schema.TaggedErrorClass()( + "ServerCliDevelopmentIconTargetMissingError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing development icon target: ${this.targetPath}. Build web first.`; + } +} + +export class ServerCliBuildAssetMissingError extends Schema.TaggedErrorClass()( + "ServerCliBuildAssetMissingError", + { + assetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`; + } +} diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index 31f2ef6f1f7..b36c2b2d496 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import process from "node:process"; -import readline from "node:readline"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeProcess from "node:process"; +import * as NodeReadline from "node:readline"; import * as NodeTimers from "node:timers"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; @@ -56,19 +56,19 @@ type PendingRequest = { reject: (error: Error) => void; }; -const targetCwd = process.argv[2] ?? process.cwd(); -const targetModel = process.argv[3] ?? "gpt-5.4"; -const promptText = process.argv[4] ?? "helo"; -const targetReasoning = process.env.CURSOR_REASONING ?? ""; -const targetContext = process.env.CURSOR_CONTEXT ?? ""; -const targetFast = process.env.CURSOR_FAST ?? ""; -const agentBin = process.env.CURSOR_AGENT_BIN ?? "agent"; -const promptWaitMs = Number(process.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); -const requestTimeoutMs = Number(process.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); +const targetCwd = NodeProcess.argv[2] ?? NodeProcess.cwd(); +const targetModel = NodeProcess.argv[3] ?? "gpt-5.4"; +const promptText = NodeProcess.argv[4] ?? "helo"; +const targetReasoning = NodeProcess.env.CURSOR_REASONING ?? ""; +const targetContext = NodeProcess.env.CURSOR_CONTEXT ?? ""; +const targetFast = NodeProcess.env.CURSOR_FAST ?? ""; +const agentBin = NodeProcess.env.CURSOR_AGENT_BIN ?? "agent"; +const promptWaitMs = Number(NodeProcess.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); +const requestTimeoutMs = Number(NodeProcess.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); function logSection(title: string, value: unknown) { - process.stdout.write(`\n=== ${title} ===\n`); - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); + NodeProcess.stdout.write(`\n=== ${title} ===\n`); + NodeProcess.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } function fail(message: string): never { @@ -124,18 +124,18 @@ function sleep(ms: number) { } class JsonRpcChild { - readonly child: ChildProcessWithoutNullStreams; + readonly child: NodeChildProcess.ChildProcessWithoutNullStreams; readonly pending = new Map(); nextId = 1; closed = false; constructor(bin: string, args: string[], cwd: string) { const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); - this.child = spawn(spawnCommand.command, spawnCommand.args, { + this.child = NodeChildProcess.spawn(spawnCommand.command, spawnCommand.args, { cwd, shell: spawnCommand.shell, stdio: ["pipe", "pipe", "pipe"], - env: process.env, + env: NodeProcess.env, }); this.child.on("exit", (code, signal) => { @@ -155,14 +155,14 @@ class JsonRpcChild { this.pending.clear(); }); - const stdout = readline.createInterface({ input: this.child.stdout }); + const stdout = NodeReadline.createInterface({ input: this.child.stdout }); stdout.on("line", (line) => { void this.handleStdoutLine(line); }); - const stderr = readline.createInterface({ input: this.child.stderr }); + const stderr = NodeReadline.createInterface({ input: this.child.stderr }); stderr.on("line", (line) => { - process.stdout.write(`[stderr] ${line}\n`); + NodeProcess.stdout.write(`[stderr] ${line}\n`); }); } @@ -175,7 +175,7 @@ class JsonRpcChild { headers: [], ...message, }); - process.stdout.write(`>>> ${payload}\n`); + NodeProcess.stdout.write(`>>> ${payload}\n`); this.child.stdin.write(`${payload}\n`); } @@ -240,13 +240,13 @@ class JsonRpcChild { return; } - process.stdout.write(`<<< ${line}\n`); + NodeProcess.stdout.write(`<<< ${line}\n`); let message: JsonRpcMessage; try { message = JSON.parse(line) as JsonRpcMessage; } catch (error) { - process.stdout.write(`[parse-error] ${(error as Error).message}\n`); + NodeProcess.stdout.write(`[parse-error] ${(error as Error).message}\n`); return; } @@ -435,7 +435,7 @@ async function main() { } void main().catch((error: unknown) => { - process.stderr.write( + NodeProcess.stderr.write( `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, ); process.exitCode = 1; diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 6abd8f48e61..f790e71f5cd 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -5,20 +5,21 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerConfig } from "../config.ts"; -import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts"; -import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; -const configLayer = ServerConfig.layerTest(process.cwd(), { +const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); const testLayer = Layer.mergeAll( configLayer, - WorkspacePathsLive, - ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), + WorkspacePaths.layer, + ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -35,6 +36,8 @@ describe("AssetAccess", () => { yield* fileSystem.writeFileString(htmlPath, ''); yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value"); + const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); + const canonicalCssPath = yield* fileSystem.realPath(cssPath); const result = yield* issueAssetUrl({ resource: { @@ -50,11 +53,11 @@ describe("AssetAccess", () => { expect(yield* resolveAsset(token, "report.html")).toEqual({ kind: "file", - path: htmlPath, + path: canonicalHtmlPath, }); expect(yield* resolveAsset(token, "report.css")).toEqual({ kind: "file", - path: cssPath, + path: canonicalCssPath, }); expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); expect(yield* resolveAsset(token, ".env")).toBeNull(); @@ -83,13 +86,100 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }).pipe(Effect.flip); - expect(error.message).toContain("relative to the project root"); + expect(error.message).toBe("Workspace file path must be relative to the project root."); + expect(error).toMatchObject({ + _tag: "AssetWorkspacePathValidationError", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("preserves non-missing canonical path failures when issuing asset URLs", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-permission-root-", + }); + const htmlPath = path.join(root, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "

report

"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: htmlPath, + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + realPath: () => Effect.fail(cause), + }); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip); + + expect(error.message).toBe("Failed to inspect the workspace asset."); + expect(error).toMatchObject({ + _tag: "AssetWorkspaceAssetInspectionError", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); + expect(error.cause).toBe(cause); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues exact workspace URLs for image previews", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-image-workspace-", + }); + const assetsDirectory = path.join(root, "assets"); + const imagePath = path.join(assetsDirectory, "icon.png"); + const siblingPath = path.join(assetsDirectory, "other.png"); + yield* fileSystem.makeDirectory(assetsDirectory, { recursive: true }); + yield* fileSystem.writeFile(imagePath, new Uint8Array([137, 80, 78, 71])); + yield* fileSystem.writeFile(siblingPath, new Uint8Array([137, 80, 78, 71])); + const canonicalImagePath = yield* fileSystem.realPath(imagePath); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: imagePath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "icon.png")).toEqual({ + kind: "file", + path: canonicalImagePath, + }); + expect(yield* resolveAsset(token, "other.png")).toBeNull(); + expect(yield* resolveAsset(token, "../icon.png")).toBeNull(); }).pipe(Effect.provide(testLayer)), ); it.effect("issues exact attachment capabilities by attachment id", () => Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; @@ -120,6 +210,7 @@ describe("AssetAccess", () => { }); const faviconPath = path.join(root, "favicon.svg"); yield* fileSystem.writeFileString(faviconPath, ""); + const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, @@ -131,7 +222,7 @@ describe("AssetAccess", () => { faviconSuffix.slice(0, faviconSeparatorIndex), faviconSuffix.slice(faviconSeparatorIndex + 1), ), - ).toEqual({ kind: "file", path: faviconPath }); + ).toEqual({ kind: "file", path: canonicalFaviconPath }); yield* fileSystem.remove(faviconPath); const fallbackResult = yield* issueAssetUrl({ @@ -147,4 +238,38 @@ describe("AssetAccess", () => { ).toEqual({ kind: "project-favicon-fallback" }); }).pipe(Effect.provide(testLayer)), ); + + it.effect("preserves structured project favicon resolution causes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-error-", + }); + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + }); + const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: root, + relativePath: "favicon.svg", + cause: platformCause, + }); + const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolvePath: () => Effect.fail(resolutionCause), + }); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe( + Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver), + Effect.flip, + ); + + expect(error.message).toBe("Failed to resolve project favicon."); + expect(error._tag).toBe("AssetProjectFaviconResolutionError"); + expect(error.cause).toBe(resolutionCause); + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 659413f4748..8d8ecbc2af3 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,10 +1,30 @@ import type { AssetResource } from "@t3tools/contracts"; -import { AssetAccessError } from "@t3tools/contracts"; +import { + AssetAttachmentNotFoundError, + AssetPreviewTypeValidationError, + AssetProjectFaviconInspectionError, + AssetProjectFaviconNotFoundError, + AssetProjectFaviconResolutionError, + AssetSigningKeyLoadError, + AssetWorkspaceAssetInspectionError, + AssetWorkspaceAssetNotFoundError, + AssetWorkspaceContextNotFoundError, + AssetWorkspacePathValidationError, + AssetWorkspaceResolutionError, + AssetWorkspaceRootNormalizationError, +} from "@t3tools/contracts"; +import { + isWorkspaceImagePreviewPath, + isWorkspacePreviewEntryPath, + WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, + WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, +} from "@t3tools/shared/filePreview"; import * as Clock from "effect/Clock"; 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 * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { @@ -13,33 +33,25 @@ import { signPayload, timingSafeEqualBase64Url, } from "../auth/utils.ts"; -import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; -import { ServerConfig } from "../config.ts"; -import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; export const FALLBACK_PROJECT_FAVICON_SVG = ``; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; -const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]); const PREVIEW_ASSET_EXTENSIONS = new Set([ - ...PREVIEWABLE_EXTENSIONS, - ".avif", + ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, + ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, ".css", - ".gif", - ".ico", - ".jpeg", - ".jpg", ".js", ".mjs", ".otf", - ".png", - ".svg", ".ttf", - ".webp", ".woff", ".woff2", ]); @@ -52,6 +64,13 @@ const AssetClaimsSchema = Schema.Union([ baseRelativePath: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-file-exact"), + workspaceRoot: Schema.String, + relativePath: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), @@ -92,40 +111,66 @@ function decodeRelativePath(value: string): string | null { } } -const failAccess = (message: string, cause?: unknown) => - new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none
()) : Effect.fail(error), + }), + ); const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; - const workspacePaths = yield* WorkspacePaths; - const resolved = yield* workspacePaths - .resolveRelativePathWithinRoot(input) - .pipe(Effect.orElseSucceed(() => null)); - if (!resolved) return null; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => Effect.succeed(Option.none()), + }), + ); + if (Option.isNone(resolved)) return null; const [canonicalRoot, canonicalFile] = yield* Effect.all([ - fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)), - fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)), + optionOnNotFound(fileSystem.realPath(input.workspaceRoot)), + optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)), ]); - if (!canonicalRoot || !canonicalFile) return null; + if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null; const path = yield* Path.Path; - const relative = path.relative(canonicalRoot, canonicalFile); + const relative = path.relative(canonicalRoot.value, canonicalFile.value); if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null; - const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null)); - return info?.type === "File" ? canonicalFile : null; + const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value)); + return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; }, ); +const resolveCanonicalWorkspaceFileForRequest = (input: { + readonly workspaceRoot: string; + readonly relativePath: string; +}) => + resolveCanonicalWorkspaceFile(input).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical asset path.", { + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; @@ -133,47 +178,92 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { - return yield* failAccess("Workspace context was not found."); + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); } - const workspaceRoot = yield* workspacePaths - .normalizeWorkspaceRoot(input.workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ + resource: input.resource, + cause, + }), + ), + ); const relativePath = path.isAbsolute(input.resource.path) ? path.relative(workspaceRoot, input.resource.path) : input.resource.path; const resolved = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); - if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { - return yield* failAccess("Only HTML and PDF files can open in the browser."); + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspacePathValidationError({ + resource: input.resource, + cause, + }), + ), + ); + if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { + return yield* new AssetPreviewTypeValidationError({ + resource: input.resource, + }); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: resolved.relativePath, - }); + }).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceAssetInspectionError({ + resource: input.resource, + cause, + }), + ), + ); if (!canonicalFile) { - return yield* failAccess("Workspace asset was not found."); + return yield* new AssetWorkspaceAssetNotFoundError({ + resource: input.resource, + }); } - claims = { - version: 1, - kind: "workspace-file", - workspaceRoot: yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), - baseRelativePath: path.dirname(resolved.relativePath), - expiresAt, - }; + const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + claims = isWorkspaceImagePreviewPath(resolved.relativePath) + ? { + version: 1, + kind: "workspace-file-exact", + workspaceRoot: canonicalWorkspaceRoot, + relativePath: resolved.relativePath, + expiresAt, + } + : { + version: 1, + kind: "workspace-file", + workspaceRoot: canonicalWorkspaceRoot, + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt, + }; fileName = path.basename(resolved.relativePath); break; } case "attachment": { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { - return yield* failAccess("Attachment was not found."); + return yield* new AssetAttachmentNotFoundError({ + resource: input.resource, + }); } claims = { version: 1, @@ -185,24 +275,54 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i break; } case "project-favicon": { - const workspaceRoot = yield* workspacePaths - .normalizeWorkspaceRoot(input.resource.cwd) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); - const faviconResolver = yield* ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ + resource: input.resource, + cause, + }), + ), + ); + const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconResolutionError({ + resource: input.resource, + cause, + }), + ), + ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if ( relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath })) + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + )) ) { - return yield* failAccess("Project favicon was not found."); + return yield* new AssetProjectFaviconNotFoundError({ + resource: input.resource, + }); } claims = { version: 1, kind: "project-favicon", - workspaceRoot: yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceResolutionError({ + resource: input.resource, + cause, + }), + ), + ), relativePath, expiresAt, }; @@ -211,10 +331,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } - const secretStore = yield* ServerSecretStore; - const signingSecret = yield* secretStore - .getOrCreateRandom(SIGNING_SECRET_NAME, 32) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.mapError( + (cause) => + new AssetSigningKeyLoadError({ + resource: input.resource, + cause, + }), + ), + ); const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { @@ -230,10 +356,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; - const secretStore = yield* ServerSecretStore; - const signingSecret = yield* secretStore - .getOrCreateRandom(SIGNING_SECRET_NAME, 32) - .pipe(Effect.orElseSucceed(() => null)); + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })), + Effect.orElseSucceed(() => null), + ); if (!signingSecret) return null; if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; @@ -241,15 +368,24 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; if (claims.kind === "attachment") { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: claims.attachmentId, }); if (!attachmentPath) return null; const fileSystem = yield* FileSystem.FileSystem; - const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null)); - return info?.type === "File" + const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to inspect attachment asset.", { + attachmentId: claims.attachmentId, + path: attachmentPath, + cause, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + return Option.isSome(info) && info.value.type === "File" ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) : null; } @@ -258,7 +394,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (claims.relativePath === null) { return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; } - const faviconPath = yield* resolveCanonicalWorkspaceFile({ + const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); @@ -268,6 +404,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; + if (claims.kind === "workspace-file-exact") { + if (decodedPath !== path.basename(claims.relativePath)) return null; + const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ + workspaceRoot: claims.workspaceRoot, + relativePath: claims.relativePath, + }); + return exactWorkspaceFile + ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset) + : null; + } const segments = decodedPath.split(/[\\/]/); if ( decodedPath.length === 0 || @@ -279,7 +425,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( } const joinedRelativePath = claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); - const workspaceFile = yield* resolveCanonicalWorkspaceFile({ + const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: joinedRelativePath, }); diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index dc7db435426..a5216f76b98 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import NodePath from "node:path"; +import * as NodePath from "node:path"; export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, ""); diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 7703902105a..e21d9cf62cf 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; @@ -45,11 +45,13 @@ describe("attachmentStore", () => { }); it("resolves attachment path by id using the extension that exists on disk", () => { - const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), + ); try { const attachmentId = "thread-1-attachment"; - const pngPath = path.join(attachmentsDir, `${attachmentId}.png`); - fs.writeFileSync(pngPath, Buffer.from("hello")); + const pngPath = NodePath.join(attachmentsDir, `${attachmentId}.png`); + NodeFS.writeFileSync(pngPath, Buffer.from("hello")); const resolved = resolveAttachmentPathById({ attachmentsDir, @@ -57,12 +59,14 @@ describe("attachmentStore", () => { }); expect(resolved).toBe(pngPath); } finally { - fs.rmSync(attachmentsDir, { recursive: true, force: true }); + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); it("returns null when no attachment file exists for the id", () => { - const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), + ); try { const resolved = resolveAttachmentPathById({ attachmentsDir, @@ -70,7 +74,7 @@ describe("attachmentStore", () => { }); expect(resolved).toBeNull(); } finally { - fs.rmSync(attachmentsDir, { recursive: true, force: true }); + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 1e8dd93f603..3d5b531db21 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -39,7 +39,7 @@ export function createAttachmentId(threadId: string): string | null { if (!threadSegment) { return null; } - return `${threadSegment}-${randomUUID()}`; + return `${threadSegment}-${NodeCrypto.randomUUID()}`; } export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { @@ -89,7 +89,7 @@ export function resolveAttachmentPathById(input: { attachmentsDir: input.attachmentsDir, relativePath: `${normalizedId}${extension}`, }); - if (maybePath && existsSync(maybePath)) { + if (maybePath && NodeFS.existsSync(maybePath)) { return maybePath; } } diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 871ec1eab60..335e0685197 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -4,27 +4,26 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ServerConfigShape } from "../config.ts"; -import { ServerConfig } from "../config.ts"; +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 * as ServerSecretStore from "./ServerSecretStore.ts"; -const makeServerConfigLayer = (overrides?: Partial) => +const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( - ServerConfig, + ServerConfig.ServerConfig, Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return { ...config, ...overrides, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); -const makeEnvironmentAuthLayer = (overrides?: Partial) => +const makeEnvironmentAuthLayer = (overrides?: Partial) => EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), @@ -33,13 +32,15 @@ const makeEnvironmentAuthLayer = (overrides?: Partial) => const makeCookieRequest = ( sessionToken: string, -): Parameters[0] => +): Parameters[0] => ({ cookies: { t3_session: sessionToken, }, headers: {}, - }) as unknown as Parameters[0]; + }) as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; const requestMetadata = { deviceType: "desktop" as const, @@ -52,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInvalidError({ - message: "Unknown bootstrap credential.", - }), + new PairingGrantStore.UnknownBootstrapCredentialError({}), ); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); - if (error._tag === "ServerAuthInvalidCredentialError") { - expect(error.reason).toBe("invalid_credential"); - } }), ); it.effect("maps unexpected bootstrap failures to 500", () => Effect.sync(() => { - const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInternalError({ - message: "Failed to consume bootstrap credential.", - cause: new Error("sqlite is unavailable"), - }), - ); + const cause = new PairingGrantStore.BootstrapCredentialConsumeError({ + cause: new Error("sqlite is unavailable"), + }); + const error = EnvironmentAuth.toBootstrapExchangeError(cause); - expect(error._tag).toBe("ServerAuthInternalError"); + expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); expect(error.message).toBe("Failed to validate bootstrap credential."); + if (error._tag === "ServerAuthBootstrapCredentialValidationError") { + expect(error.cause).toBe(cause); + } }), ); @@ -116,10 +113,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ) .pipe(Effect.flip); - expect(error._tag).toBe("ServerAuthInvalidRequestError"); - if (error._tag === "ServerAuthInvalidRequestError") { - expect(error.reason).toBe("scope_not_granted"); - } + expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index d8c0079089f..dd53a83ca95 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -20,12 +20,12 @@ import { import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; @@ -67,123 +67,429 @@ export interface AuthenticatedSession { readonly expiresAt?: DateTime.DateTime; } -export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const serverAuthInternalErrorContext = { + cause: Schema.Defect(), +}; + +export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthBootstrapCredentialValidationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate bootstrap credential."; + } +} + +export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionCredentialValidationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate session credential."; + } +} + +export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedSessionIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated session."; + } +} + +export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedAccessTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated access token."; + } +} + +export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkCreationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to create pairing link."; + } +} + +export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinksListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list pairing links."; + } +} + +export class ServerAuthPairingLinkRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke pairing link."; + } +} + +export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthSessionTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session token."; + } +} + +export class ServerAuthSessionsListError extends Schema.TaggedErrorClass()( + "ServerAuthSessionsListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list sessions."; + } +} + +export class ServerAuthSessionRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthOtherSessionsRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke other sessions."; + } +} + +export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthWebSocketTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayStateRecordError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to record DPoP proof replay state."; + } +} + +export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayKeyCalculationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to calculate DPoP replay key."; + } +} + +export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountVerificationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not verify the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountReadError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not read the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountMissingError", + {}, +) { + override get message(): string { + return "Cloud linked user is not installed for this environment."; + } +} + +export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudLinkJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud link JWT."; + } +} + +export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintPublicKeyMissingError", + {}, +) { + override get message(): string { + return "Cloud mint public key is not installed for this environment."; + } +} + +export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudRelayIssuerMissingError", + {}, +) { + override get message(): string { + return "Cloud relay issuer is not installed for this environment."; + } +} -export class ServerAuthInvalidCredentialError extends Data.TaggedError( +export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudHealthJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud health JWT."; + } +} + +export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud mint JWT."; + } +} + +export const ServerAuthInternalError = Schema.Union([ + ServerAuthBootstrapCredentialValidationError, + ServerAuthSessionCredentialValidationError, + ServerAuthAuthenticatedSessionIssueError, + ServerAuthAuthenticatedAccessTokenIssueError, + ServerAuthPairingLinkCreationError, + ServerAuthPairingLinksListError, + ServerAuthPairingLinkRevocationError, + ServerAuthSessionTokenIssueError, + ServerAuthSessionsListError, + ServerAuthSessionRevocationError, + ServerAuthOtherSessionsRevocationError, + ServerAuthWebSocketTokenIssueError, + ServerAuthDpopReplayStateRecordError, + ServerAuthDpopReplayKeyCalculationError, + ServerAuthLinkedCloudAccountVerificationError, + ServerAuthLinkedCloudAccountReadError, + ServerAuthLinkedCloudAccountMissingError, + ServerAuthCloudLinkJwtSigningError, + ServerAuthCloudMintPublicKeyMissingError, + ServerAuthCloudRelayIssuerMissingError, + ServerAuthCloudHealthJwtSigningError, + ServerAuthCloudMintJwtSigningError, +]); +export type ServerAuthInternalError = typeof ServerAuthInternalError.Type; +export const isServerAuthInternalError = Schema.is(ServerAuthInternalError); + +export class ServerAuthMissingCredentialError extends Schema.TaggedErrorClass()( + "ServerAuthMissingCredentialError", + {}, +) { + override get message(): string { + return "Server authentication credential is missing."; + } +} + +export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass()( "ServerAuthInvalidCredentialError", -)<{ - readonly reason: "missing_credential" | "invalid_credential"; - readonly cause?: unknown; -}> {} - -export class ServerAuthInvalidRequestError extends Data.TaggedError( - "ServerAuthInvalidRequestError", -)<{ - readonly reason: "invalid_scope" | "scope_not_granted"; -}> {} - -export class ServerAuthForbiddenOperationError extends Data.TaggedError( - "ServerAuthForbiddenOperationError", -)<{ - readonly reason: "current_session_revoke_not_allowed"; -}> {} + { + diagnostic: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return "Server authentication credential is invalid."; + } +} -export interface EnvironmentAuthShape { - readonly getDescriptor: () => Effect.Effect; - readonly getSessionState: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly createBrowserSession: ( - credential: string, - requestMetadata: AuthClientMetadata, - ) => Effect.Effect< - { - readonly response: AuthBrowserSessionResult; - readonly sessionToken: string; - }, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly exchangeBootstrapCredentialForAccessToken: ( - credential: string, - requestedScopes: ReadonlyArray | undefined, - requestMetadata: AuthClientMetadata, - input?: { - readonly proofKeyThumbprint?: string; - }, - ) => Effect.Effect< - AuthAccessTokenResult, - ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError - >; - readonly createPairingLink: (input?: { - readonly ttl?: Duration.Duration; - readonly label?: string; - readonly scopes?: ReadonlyArray; - readonly subject?: string; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly issuePairingCredential: ( - input?: AuthCreatePairingCredentialInput, - ) => Effect.Effect; - readonly issueStartupPairingCredential: () => Effect.Effect< - AuthPairingCredentialResult, - ServerAuthInternalError - >; - readonly listPairingLinks: (input?: { - readonly excludeSubjects?: ReadonlyArray; - }) => Effect.Effect, ServerAuthInternalError>; - readonly revokePairingLink: (id: string) => Effect.Effect; - readonly issueSession: (input?: { - readonly ttl?: Duration.Duration; - readonly subject?: string; - readonly scopes?: ReadonlyArray; - readonly label?: string; - }) => Effect.Effect; - readonly listSessions: () => Effect.Effect< - ReadonlyArray, - ServerAuthInternalError - >; - readonly revokeSession: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeOtherSessionsExcept: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly listClientSessions: ( - currentSessionId: AuthSessionId, - ) => Effect.Effect, ServerAuthInternalError>; - readonly revokeClientSession: ( - currentSessionId: AuthSessionId, - targetSessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeOtherClientSessions: ( - currentSessionId: AuthSessionId, - ) => Effect.Effect; - readonly authenticateHttpRequest: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect< - AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly authenticateWebSocketUpgrade: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect< - AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly issueWebSocketTicket: ( - session: Pick, - ) => Effect.Effect; - readonly issueStartupPairingUrl: ( - baseUrl: string, - ) => Effect.Effect; +export const ServerAuthCredentialError = Schema.Union([ + ServerAuthMissingCredentialError, + ServerAuthInvalidCredentialError, +]); +export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type; +export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError); +export const serverAuthCredentialReason = ( + error: ServerAuthCredentialError, +): "missing_credential" | "invalid_credential" => + error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; + +export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( + "ServerAuthInvalidScopeError", + {}, +) { + override get message(): string { + return "The requested authentication scope is invalid."; + } } -export class EnvironmentAuth extends Context.Service()( - "t3/auth/EnvironmentAuth", -) {} +export class ServerAuthScopeNotGrantedError extends Schema.TaggedErrorClass()( + "ServerAuthScopeNotGrantedError", + {}, +) { + override get message(): string { + return "The requested authentication scope was not granted."; + } +} + +export const ServerAuthInvalidRequestError = Schema.Union([ + ServerAuthInvalidScopeError, + ServerAuthScopeNotGrantedError, +]); +export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type; +export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError); +export const serverAuthInvalidRequestReason = ( + error: ServerAuthInvalidRequestError, +): "invalid_scope" | "scope_not_granted" => + error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; + +export class ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass()( + "ServerAuthForbiddenOperationError", + {}, +) { + override get message(): string { + return "The current authentication session cannot revoke itself."; + } +} + +export class EnvironmentAuth extends Context.Service< + EnvironmentAuth, + { + readonly getDescriptor: () => Effect.Effect; + readonly getSessionState: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly createBrowserSession: ( + credential: string, + requestMetadata: AuthClientMetadata, + ) => Effect.Effect< + { + readonly response: AuthBrowserSessionResult; + readonly sessionToken: string; + }, + ServerAuthInvalidCredentialError | ServerAuthInternalError + >; + readonly exchangeBootstrapCredentialForAccessToken: ( + credential: string, + requestedScopes: ReadonlyArray | undefined, + requestMetadata: AuthClientMetadata, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect< + AuthAccessTokenResult, + ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + >; + readonly createPairingLink: (input?: { + readonly ttl?: Duration.Duration; + readonly label?: string; + readonly scopes?: ReadonlyArray; + readonly subject?: string; + readonly proofKeyThumbprint?: string; + }) => Effect.Effect; + readonly issuePairingCredential: ( + input?: AuthCreatePairingCredentialInput, + ) => Effect.Effect; + readonly issueStartupPairingCredential: () => Effect.Effect< + AuthPairingCredentialResult, + ServerAuthInternalError + >; + readonly listPairingLinks: (input?: { + readonly excludeSubjects?: ReadonlyArray; + }) => Effect.Effect, ServerAuthInternalError>; + readonly revokePairingLink: (id: string) => Effect.Effect; + readonly issueSession: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly scopes?: ReadonlyArray; + readonly label?: string; + }) => Effect.Effect; + readonly listSessions: () => Effect.Effect< + ReadonlyArray, + ServerAuthInternalError + >; + readonly revokeSession: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherSessionsExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly listClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect, ServerAuthInternalError>; + readonly revokeClientSession: ( + currentSessionId: AuthSessionId, + targetSessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly issueWebSocketTicket: ( + session: Pick, + ) => Effect.Effect; + readonly issueStartupPairingUrl: ( + baseUrl: string, + ) => Effect.Effect; + } +>()("t3/auth/EnvironmentAuth") {} type BootstrapExchangeResult = { readonly response: AuthBrowserSessionResult; @@ -206,23 +512,14 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; }; -const toInternalError = - (message: string) => - (cause: unknown): ServerAuthInternalError => - new ServerAuthInternalError({ message, cause }); - export function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError { - if (cause._tag === "BootstrapCredentialInternalError") { - return new ServerAuthInternalError({ - message: "Failed to validate bootstrap credential.", - cause, - }); + if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { + return new ServerAuthBootstrapCredentialValidationError({ cause }); } return new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", cause, }); } @@ -231,17 +528,11 @@ const mapSessionVerificationErrors = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTags({ - SessionCredentialInvalidError: (cause) => - Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })), - SessionCredentialInternalError: (cause) => - Effect.fail( - new ServerAuthInternalError({ - message: "Failed to validate session credential.", - cause, - }), - ), - }), + Effect.mapError((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? new ServerAuthInvalidCredentialError({ cause }) + : new ServerAuthSessionCredentialValidationError({ cause }), + ), ); function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { @@ -262,7 +553,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } -export const make = Effect.fn("makeEnvironmentAuth")(function* () { +export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; @@ -277,12 +568,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ServerAuthInvalidCredentialError | ServerAuthInternalError > => sessions.verify(token).pipe( - Effect.tapErrorTag("SessionCredentialInvalidError", (cause) => - Effect.logWarning("Rejected authenticated session credential.").pipe( - Effect.annotateLogs({ - reason: cause.message, - }), - ), + Effect.tapError((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? Effect.logWarning("Rejected authenticated session credential.").pipe( + Effect.annotateLogs({ + reason: cause.message, + }), + ) + : Effect.void, ), Effect.map((session) => ({ sessionId: session.sessionId, @@ -295,13 +588,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { mapSessionVerificationErrors, ); - const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { + const authenticateRequest = ( + request: HttpServerRequest.HttpServerRequest, + ): Effect.Effect => { const cookieToken = request.cookies[sessions.cookieName]; const bearerToken = parseBearerToken(request); const dpopToken = parseDpopToken(request); const credential = cookieToken ?? bearerToken ?? dpopToken; if (!credential) { - return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); + return Effect.fail(new ServerAuthMissingCredentialError({})); } return authenticateToken(credential).pipe( Effect.flatMap((session) => { @@ -309,8 +604,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { if (!dpopToken || dpopToken !== credential) { return Effect.fail( new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP-bound access token requires DPoP authorization.", + diagnostic: "DPoP-bound access token requires DPoP authorization.", }), ); } @@ -327,8 +621,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { if (dpopToken) { return Effect.fail( new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP authorization requires a proof-bound access token.", + diagnostic: "DPoP authorization requires a proof-bound access token.", }), ); } @@ -337,7 +630,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ); }; - const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => + const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) => authenticateRequest(request).pipe( Effect.map( (session) => @@ -349,7 +642,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, ), - Effect.catchTag("ServerAuthInvalidCredentialError", () => + Effect.catchIf(isServerAuthCredentialError, () => Effect.succeed({ authenticated: false, auth: descriptor, @@ -358,7 +651,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.getSessionState"), ); - const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = ( + const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( credential, requestMetadata, ) => @@ -376,13 +669,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }, }) .pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated session.", - cause, - }), - ), + Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), ), ), Effect.map( @@ -400,7 +687,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); - const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = + const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => bootstrapCredentials.consume(credential, input).pipe( Effect.mapError(toBootstrapExchangeError), @@ -408,9 +695,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthInvalidRequestError({ - reason: "scope_not_granted", - }); + return yield* new ServerAuthScopeNotGrantedError({}); } return yield* sessions .issue({ @@ -430,11 +715,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }) .pipe( Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated access token.", - cause, - }), + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), ), ); }), @@ -482,7 +763,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ), ); - const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn( + const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn( "EnvironmentAuth.createPairingLink", )( function* (input) { @@ -504,10 +785,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), } satisfies IssuedPairingLink; }, - Effect.mapError(toInternalError("Failed to create pairing link.")), + Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), ); - const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) => + const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => bootstrapCredentials.listActive().pipe( Effect.map((pairingLinks) => { const excludedSubjects = input?.excludeSubjects ?? [ @@ -519,19 +800,17 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, ); }), - Effect.mapError(toInternalError("Failed to list pairing links.")), + Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })), Effect.withSpan("EnvironmentAuth.listPairingLinks"), ); - const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) => - bootstrapCredentials - .revoke(id) - .pipe( - Effect.mapError(toInternalError("Failed to revoke pairing link.")), - Effect.withSpan("EnvironmentAuth.revokePairingLink"), - ); + const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => + bootstrapCredentials.revoke(id).pipe( + Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokePairingLink"), + ); - const issueSession: EnvironmentAuthShape["issueSession"] = (input) => + const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => sessions .issue({ subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, @@ -556,49 +835,46 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), - Effect.mapError(toInternalError("Failed to issue session token.")), + Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueSession"), ); - const listSessions: EnvironmentAuthShape["listSessions"] = () => + const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => sessions.listActive().pipe( Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), - Effect.mapError(toInternalError("Failed to list sessions.")), + Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })), Effect.withSpan("EnvironmentAuth.listSessions"), ); - const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) => - sessions - .revoke(sessionId) - .pipe( - Effect.mapError(toInternalError("Failed to revoke session.")), - Effect.withSpan("EnvironmentAuth.revokeSession"), - ); + const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => + sessions.revoke(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeSession"), + ); - const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = ( + const revokeOtherSessionsExcept: EnvironmentAuth["Service"]["revokeOtherSessionsExcept"] = ( sessionId, ) => - sessions - .revokeAllExcept(sessionId) - .pipe( - Effect.mapError(toInternalError("Failed to revoke other sessions.")), - Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), - ); + sessions.revokeAllExcept(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), + ); - const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) => + const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) => issuePairingCredentialForSubject({ scopes: input?.scopes ?? AuthStandardClientScopes, subject: "one-time-token", ...(input?.label ? { label: input.label } : {}), }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); - const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () => - issuePairingCredentialForSubject({ - scopes: AuthAdministrativeScopes, - subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, - }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = + () => + issuePairingCredentialForSubject({ + scopes: AuthAdministrativeScopes, + subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); - const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) => + const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( Effect.map((clientSessions) => clientSessions.map( @@ -611,25 +887,23 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.listClientSessions"), ); - const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn( + const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn( "EnvironmentAuth.revokeClientSession", )(function* (currentSessionId, targetSessionId) { if (currentSessionId === targetSessionId) { - return yield* new ServerAuthForbiddenOperationError({ - reason: "current_session_revoke_not_allowed", - }); + return yield* new ServerAuthForbiddenOperationError({}); } return yield* revokeSession(targetSessionId); }); - const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = ( + const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = ( currentSessionId, ) => revokeOtherSessionsExcept(currentSessionId).pipe( Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), ); - const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) => + const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) => issueStartupPairingCredential().pipe( Effect.map((issued) => { const url = new URL(baseUrl); @@ -641,15 +915,9 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"), ); - const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) => + const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) => sessions.issueWebSocketToken(session.sessionId).pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue websocket token.", - cause, - }), - ), + Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), Effect.map( (issued) => ({ @@ -660,10 +928,12 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"), ); - const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) => + const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = ( + request, + ) => authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")); - const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] = + const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] = Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) { const requestUrl = HttpServerRequest.toURL(request); if (Option.isSome(requestUrl)) { @@ -685,7 +955,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { return yield* authenticateRequest(request); }); - return { + return EnvironmentAuth.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), getSessionState, @@ -707,10 +977,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { authenticateWebSocketUpgrade, issueWebSocketTicket, issueStartupPairingUrl, - } satisfies EnvironmentAuthShape; + }); }); -export const layer = Layer.effect(EnvironmentAuth, make()).pipe( +export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(PairingGrantStore.layer), Layer.provideMerge(SessionStore.layer), Layer.provideMerge(EnvironmentAuthPolicy.layer), diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 44c28dea416..03009270e15 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -3,31 +3,34 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ServerConfigShape } from "../config.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; const makeServerConfigLayer = ( - overrides?: Partial>, + overrides?: Partial>, ) => Layer.effect( - ServerConfig, + ServerConfig.ServerConfig, Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return { ...config, ...overrides, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-auth-control-plane-test-", + }), + ), ); const makeEnvironmentAuthLayer = ( - overrides?: Partial>, + overrides?: Partial>, ) => EnvironmentAuth.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index c9f5dc6230d..95269fb6c37 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -3,21 +3,22 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ServerConfigShape } from "../config.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; -const makeEnvironmentAuthPolicyLayer = (overrides?: Partial) => +const makeEnvironmentAuthPolicyLayer = ( + overrides?: Partial, +) => EnvironmentAuthPolicy.layer.pipe( Layer.provide( Layer.effect( - ServerConfig, + ServerConfig.ServerConfig, Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return { ...config, ...overrides, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 205c85b0234..7ffef0ff0a5 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -3,21 +3,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { resolveSessionCookieName } from "./utils.ts"; import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; -export interface EnvironmentAuthPolicyShape { - readonly getDescriptor: () => Effect.Effect; -} - export class EnvironmentAuthPolicy extends Context.Service< EnvironmentAuthPolicy, - EnvironmentAuthPolicyShape + { + readonly getDescriptor: () => Effect.Effect; + } >()("t3/auth/EnvironmentAuthPolicy") {} -export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { - const config = yield* ServerConfig; +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); const policy = @@ -46,10 +44,10 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { }), }; - return { + return EnvironmentAuthPolicy.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")), - } satisfies EnvironmentAuthPolicyShape; + }); }); -export const layer = Layer.effect(EnvironmentAuthPolicy, make()); +export const layer = Layer.effect(EnvironmentAuthPolicy, make); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 3861b4fc78f..53b1a7e7929 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -3,37 +3,59 @@ import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; -import type { ServerConfigShape } from "../config.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; +import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; const makeServerConfigLayer = ( - overrides?: Partial>, + overrides?: Partial>, ) => Layer.effect( - ServerConfig, + ServerConfig.ServerConfig, Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return { ...config, ...overrides, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), ); const makePairingGrantStoreLayer = ( - overrides?: Partial>, + overrides?: Partial>, ) => PairingGrantStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(makeServerConfigLayer(overrides)), ); +const makePairingGrantStoreTestLayer = ( + overrides: Partial, +) => + Layer.effect(PairingGrantStore.PairingGrantStore, PairingGrantStore.make).pipe( + Layer.provide( + Layer.succeed( + AuthPairingLinks.AuthPairingLinkRepository, + AuthPairingLinks.AuthPairingLinkRepository.of({ + create: () => Effect.void, + consumeAvailable: () => Effect.succeed(Option.none()), + listActive: () => Effect.succeed([]), + revoke: () => Effect.succeed(false), + getByCredential: () => Effect.succeed(Option.none()), + ...overrides, + }), + ), + ), + Layer.provide(makeServerConfigLayer()), + ); + it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { it.effect("issues pairing tokens in a short manual-entry format", () => Effect.gen(function* () { @@ -62,7 +84,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); - expect(second._tag).toBe("BootstrapCredentialInvalidError"); + expect(second._tag).toBe("UnknownBootstrapCredentialError"); expect(second.message).toContain("Unknown bootstrap credential"); }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); @@ -86,7 +108,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(successes).toHaveLength(1); expect(failures).toHaveLength(7); for (const failure of failures) { - expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError"); + expect(failure.failure._tag).toBe("UnknownBootstrapCredentialError"); expect(failure.failure.message).toContain("Unknown bootstrap credential"); } }).pipe(Effect.provide(makePairingGrantStoreLayer())), @@ -133,7 +155,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { "relay:write", ]); expect(first.subject).toBe("desktop-bootstrap"); - expect(second._tag).toBe("BootstrapCredentialInvalidError"); + expect(second._tag).toBe("UnknownBootstrapCredentialError"); }).pipe( Effect.provide( makePairingGrantStoreLayer({ @@ -150,7 +172,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { yield* TestClock.adjust(Duration.minutes(6)); const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); - expect(expired._tag).toBe("BootstrapCredentialInvalidError"); + expect(expired._tag).toBe("ExpiredBootstrapCredentialError"); expect(expired.message).toContain("Bootstrap credential expired"); }).pipe( Effect.provide( @@ -184,7 +206,31 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); expect(revokedConsume.message).toContain("no longer available"); - expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError"); + expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError"); }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); + + it.effect("identifies consume-available failures and preserves their cause", () => { + const repositoryFailure = new PersistenceSqlError({ + operation: "consume-pairing-link", + detail: "Database unavailable", + cause: new Error("database unavailable"), + }); + + return Effect.gen(function* () { + const pairingGrants = yield* PairingGrantStore.PairingGrantStore; + const error = yield* Effect.flip(pairingGrants.consume("credential")); + + if (error._tag !== "BootstrapCredentialConsumeAvailableError") { + return yield* Effect.die(error); + } + expect(error.cause).toBe(repositoryFailure); + }).pipe( + Effect.provide( + makePairingGrantStoreTestLayer({ + consumeAvailable: () => Effect.fail(repositoryFailure), + }), + ), + ); + }); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index e97696fbadd..7a8fb9477cf 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -7,19 +7,18 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as Option from "effect/Option"; -import { ServerConfig } from "../config.ts"; -import { AuthPairingLinkRepositoryLive } from "../persistence/Layers/AuthPairingLinks.ts"; -import { AuthPairingLinkRepository } from "../persistence/Services/AuthPairingLinks.ts"; +import * as ServerConfig from "../config.ts"; +import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { readonly method: ServerAuthBootstrapMethod; @@ -30,22 +29,151 @@ export interface BootstrapGrant { readonly expiresAt: DateTime.DateTime; } -export class BootstrapCredentialInvalidError extends Data.TaggedError( - "BootstrapCredentialInvalidError", -)<{ - readonly message: string; -}> {} +export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass()( + "UnknownBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Unknown bootstrap credential."; + } +} -export class BootstrapCredentialInternalError extends Data.TaggedError( - "BootstrapCredentialInternalError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class ExpiredBootstrapCredentialError extends Schema.TaggedErrorClass()( + "ExpiredBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Bootstrap credential expired."; + } +} + +export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedErrorClass()( + "BootstrapCredentialProofKeyMismatchError", + {}, +) { + override get message(): string { + return "Bootstrap credential proof key mismatch."; + } +} + +export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass()( + "UnavailableBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Bootstrap credential is no longer available."; + } +} + +export const BootstrapCredentialInvalidError = Schema.Union([ + UnknownBootstrapCredentialError, + ExpiredBootstrapCredentialError, + BootstrapCredentialProofKeyMismatchError, + UnavailableBootstrapCredentialError, +]); +export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; +export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError); + +export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass()( + "ActivePairingLinksLoadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load active pairing links."; + } +} + +export class PairingLinkRevokeError extends Schema.TaggedErrorClass()( + "PairingLinkRevokeError", + { + pairingLinkId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to revoke pairing link '${this.pairingLinkId}'.`; + } +} + +export class PairingCredentialIssueError extends Schema.TaggedErrorClass()( + "PairingCredentialIssueError", + { + pairingLinkId: Schema.String, + subject: Schema.String, + label: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to issue pairing credential '${this.pairingLinkId}' for '${this.subject}'.`; + } +} + +export class PairingCredentialRandomGenerationError extends Schema.TaggedErrorClass()( + "PairingCredentialRandomGenerationError", + { + operation: Schema.Literals(["generate-id", "generate-token"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to generate pairing credential data during '${this.operation}'.`; + } +} + +export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass()( + "BootstrapCredentialConsumeError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to consume bootstrap credential."; + } +} + +export class BootstrapCredentialConsumeAvailableError extends Schema.TaggedErrorClass()( + "BootstrapCredentialConsumeAvailableError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to atomically consume an available bootstrap credential."; + } +} + +export class BootstrapCredentialLookupError extends Schema.TaggedErrorClass()( + "BootstrapCredentialLookupError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to look up bootstrap credential state."; + } +} -export type BootstrapCredentialError = - | BootstrapCredentialInvalidError - | BootstrapCredentialInternalError; +export const BootstrapCredentialInternalError = Schema.Union([ + ActivePairingLinksLoadError, + PairingLinkRevokeError, + PairingCredentialIssueError, + PairingCredentialRandomGenerationError, + BootstrapCredentialConsumeError, + BootstrapCredentialConsumeAvailableError, + BootstrapCredentialLookupError, +]); +export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type; +export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError); + +export const BootstrapCredentialError = Schema.Union([ + BootstrapCredentialInvalidError, + BootstrapCredentialInternalError, +]); +export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; +export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError); export interface IssuedBootstrapCredential { readonly id: string; @@ -65,31 +193,30 @@ export type BootstrapCredentialChange = readonly id: string; }; -export interface PairingGrantStoreShape { - readonly issueOneTimeToken: (input?: { - readonly ttl?: Duration.Duration; - readonly scopes?: ReadonlyArray; - readonly subject?: string; - readonly label?: string; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly listActive: () => Effect.Effect< - ReadonlyArray, - BootstrapCredentialInternalError - >; - readonly streamChanges: Stream.Stream; - readonly revoke: (id: string) => Effect.Effect; - readonly consume: ( - credential: string, - input?: { +export class PairingGrantStore extends Context.Service< + PairingGrantStore, + { + readonly issueOneTimeToken: (input?: { + readonly ttl?: Duration.Duration; + readonly scopes?: ReadonlyArray; + readonly subject?: string; + readonly label?: string; readonly proofKeyThumbprint?: string; - }, - ) => Effect.Effect; -} - -export class PairingGrantStore extends Context.Service()( - "t3/auth/PairingGrantStore", -) {} + }) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + BootstrapCredentialInternalError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (id: string) => Effect.Effect; + readonly consume: ( + credential: string, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect; + } +>()("t3/auth/PairingGrantStore") {} interface StoredBootstrapGrant extends BootstrapGrant { readonly remainingUses: number | "unbounded"; @@ -112,27 +239,23 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; -const invalidBootstrapCredentialError = (message: string) => - new BootstrapCredentialInvalidError({ - message, - }); - -const internalBootstrapCredentialError = (message: string, cause: unknown) => - new BootstrapCredentialInternalError({ - message, - cause, - }); - -export const make = Effect.fn("makePairingGrantStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - const config = yield* ServerConfig; - const pairingLinks = yield* AuthPairingLinkRepository; + const config = yield* ServerConfig.ServerConfig; + const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const seededGrantsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); const generatePairingToken = Effect.gen(function* () { let credential = ""; while (credential.length < PAIRING_TOKEN_LENGTH) { - const bytes = yield* crypto.randomBytes(PAIRING_TOKEN_LENGTH); + const bytes = yield* crypto + .randomBytes(PAIRING_TOKEN_LENGTH) + .pipe( + Effect.mapError( + (cause) => + new PairingCredentialRandomGenerationError({ operation: "generate-token", cause }), + ), + ); for (const byte of bytes) { if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) { continue; @@ -178,10 +301,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); } - const toBootstrapCredentialError = (message: string) => (cause: unknown) => - internalBootstrapCredentialError(message, cause); - - const listActive: PairingGrantStoreShape["listActive"] = Effect.fn( + const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", )( function* () { @@ -209,66 +329,81 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } satisfies AuthPairingLink), ); }, - Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")), + Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })), ); - const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")( + const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")( function* (id) { const revokedAt = yield* DateTime.now; - const revoked = yield* pairingLinks.revoke({ - id, - revokedAt, - }); + const revoked = yield* pairingLinks + .revoke({ + id, + revokedAt, + }) + .pipe(Effect.mapError((cause) => new PairingLinkRevokeError({ pairingLinkId: id, cause }))); if (revoked) { yield* emitRemoved(id); } return revoked; }, - Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")), ); - const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn( + const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( "PairingGrantStore.issueOneTimeToken", - )( - function* (input) { - const id = yield* crypto.randomUUIDv4; - const credential = yield* generatePairingToken; - const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; - const now = yield* DateTime.now; - const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); - const issued: IssuedBootstrapCredential = { - id, - credential, - ...(input?.label ? { label: input.label } : {}), - ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), - expiresAt, - }; - yield* pairingLinks.create({ + )(function* (input) { + const id = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new PairingCredentialRandomGenerationError({ operation: "generate-id", cause }), + ), + ); + const credential = yield* generatePairingToken; + const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); + const issued: IssuedBootstrapCredential = { + id, + credential, + ...(input?.label ? { label: input.label } : {}), + ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), + expiresAt, + }; + const subject = input?.subject ?? "one-time-token"; + yield* pairingLinks + .create({ id, credential, method: "one-time-token", scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", + subject, label: input?.label ?? null, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, createdAt: now, expiresAt: expiresAt, - }); - yield* emitUpsert({ - id, - credential, - scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", - ...(input?.label ? { label: input.label } : {}), - createdAt: now, - expiresAt, - }); - return issued; - }, - Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")), - ); + }) + .pipe( + Effect.mapError( + (cause) => + new PairingCredentialIssueError({ + pairingLinkId: id, + subject, + ...(input?.label ? { label: input.label } : {}), + cause, + }), + ), + ); + yield* emitUpsert({ + id, + credential, + scopes: input?.scopes ?? AuthStandardClientScopes, + subject: input?.subject ?? "one-time-token", + ...(input?.label ? { label: input.label } : {}), + createdAt: now, + expiresAt, + }); + return issued; + }); - const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( + const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( function* (credential, input) { const now = yield* DateTime.now; const seededResult: ConsumeResult = yield* Ref.modify( @@ -280,7 +415,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Unknown bootstrap credential."), + error: new UnknownBootstrapCredentialError({}), }, current, ]; @@ -293,7 +428,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "expired", - error: invalidBootstrapCredentialError("Bootstrap credential expired."), + error: new ExpiredBootstrapCredentialError({}), }, next, ]; @@ -304,7 +439,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), + error: new BootstrapCredentialProofKeyMismatchError({}), }, next, ]; @@ -348,12 +483,14 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { return yield* seededResult.error; } - const consumed = yield* pairingLinks.consumeAvailable({ - credential, - proofKeyThumbprint: input?.proofKeyThumbprint ?? null, - consumedAt: now, - now, - }); + const consumed = yield* pairingLinks + .consumeAvailable({ + credential, + proofKeyThumbprint: input?.proofKeyThumbprint ?? null, + consumedAt: now, + now, + }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause }))); if (Option.isSome(consumed)) { yield* emitRemoved(consumed.value.id); @@ -369,43 +506,37 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } satisfies BootstrapGrant; } - const matching = yield* pairingLinks.getByCredential({ credential }); + const matching = yield* pairingLinks + .getByCredential({ credential }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); if (Option.isNone(matching)) { - return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + return yield* new UnknownBootstrapCredentialError({}); } if (matching.value.revokedAt !== null) { - return yield* invalidBootstrapCredentialError( - "Bootstrap credential is no longer available.", - ); + return yield* new UnavailableBootstrapCredentialError({}); } if (matching.value.consumedAt !== null) { - return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + return yield* new UnknownBootstrapCredentialError({}); } if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { - return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); + return yield* new ExpiredBootstrapCredentialError({}); } if ( matching.value.proofKeyThumbprint !== null && matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint ) { - return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."); + return yield* new BootstrapCredentialProofKeyMismatchError({}); } - return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); + return yield* new UnavailableBootstrapCredentialError({}); }, - Effect.mapError((cause) => - cause._tag === "BootstrapCredentialInvalidError" || - cause._tag === "BootstrapCredentialInternalError" - ? cause - : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), - ), ); - return { + return PairingGrantStore.of({ issueOneTimeToken, listActive, get streamChanges() { @@ -413,9 +544,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }, revoke, consume, - } satisfies PairingGrantStoreShape; + }); }); -export const layer = Layer.effect(PairingGrantStore, make()).pipe( - Layer.provideMerge(AuthPairingLinkRepositoryLive), +export const layer = Layer.effect(PairingGrantStore, make).pipe( + Layer.provideMerge(AuthPairingLinks.layer), ); diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index 93339f4d4db..d4411fb9f3b 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -1,14 +1,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; 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 Ref from "effect/Ref"; import * as PlatformError from "effect/PlatformError"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; const makeServerConfigLayer = () => @@ -145,13 +146,13 @@ const makeConcurrentCreateSecretStoreLayer = () => ); it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { - it.effect("returns null when a secret file does not exist", () => + it.effect("returns Option.none when a secret file does not exist", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; const secret = yield* secretStore.get("missing-secret"); - expect(secret).toBeNull(); + assert.isTrue(Option.isNone(secret)); }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); @@ -162,7 +163,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); - expect(Array.from(second)).toEqual(Array.from(first)); + assert.deepEqual(Array.from(second), Array.from(first)); }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); @@ -178,10 +179,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { { concurrency: "unbounded" }, ); const persisted = yield* secretStore.get("session-signing-key"); + const persistedBytes = Option.getOrThrow(persisted); - expect(persisted).not.toBeNull(); - expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array())); - expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array())); + assert.deepEqual(Array.from(first), Array.from(persistedBytes)); + assert.deepEqual(Array.from(second), Array.from(persistedBytes)); }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), ); @@ -217,10 +218,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); - expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( - true, + assert.isTrue( + chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets")), ); - expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); + assert.isAtLeast(chmodCalls.filter((call) => call.mode === 0o600).length, 2); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -230,10 +231,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); - expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); - expect(error.message).toContain("Failed to read secret session-signing-key."); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); - expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + assert.instanceOf(error, ServerSecretStore.SecretStoreReadError); + assert.include(error.message, "Failed to read secret session-signing-key."); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), ); @@ -245,10 +246,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), ); - expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); - expect(error.message).toContain("Failed to persist secret session-signing-key."); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); - expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + assert.instanceOf(error, ServerSecretStore.SecretStorePersistError); + assert.include(error.message, "Failed to persist secret session-signing-key."); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), ); @@ -258,10 +259,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.remove("session-signing-key")); - expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); - expect(error.message).toContain("Failed to remove secret session-signing-key."); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); - expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError); + assert.include(error.message, "Failed to remove secret session-signing-key."); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), ); }); diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 3b84ba58377..5e9890c1ea2 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -1,53 +1,166 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; 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"; import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; -export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const secretStoreErrorContext = { + resource: Schema.String, + cause: Schema.Defect(), +}; + +export class SecretStoreSecureError extends Schema.TaggedErrorClass()( + "SecretStoreSecureError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to secure ${this.resource}.`; + } +} + +export class SecretStoreReadError extends Schema.TaggedErrorClass()( + "SecretStoreReadError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to read ${this.resource}.`; + } +} + +export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass()( + "SecretStoreTemporaryPathError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to create temporary path for ${this.resource}.`; + } +} + +export class SecretStorePersistError extends Schema.TaggedErrorClass()( + "SecretStorePersistError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to persist ${this.resource}.`; + } +} + +export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass()( + "SecretStoreRandomGenerationError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to generate random bytes for ${this.resource}.`; + } +} + +export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass()( + "SecretStoreConcurrentReadError", + { + resource: Schema.String, + }, +) { + override get message(): string { + return `Failed to read ${this.resource} after concurrent creation.`; + } +} + +export class SecretStoreRemoveError extends Schema.TaggedErrorClass()( + "SecretStoreRemoveError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to remove ${this.resource}.`; + } +} + +export class SecretStoreDecodeError extends Schema.TaggedErrorClass()( + "SecretStoreDecodeError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to decode ${this.resource}.`; + } +} + +export class SecretStoreEncodeError extends Schema.TaggedErrorClass()( + "SecretStoreEncodeError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to encode ${this.resource}.`; + } +} + +export const SecretStoreError = Schema.Union([ + SecretStoreSecureError, + SecretStoreReadError, + SecretStoreTemporaryPathError, + SecretStorePersistError, + SecretStoreRandomGenerationError, + SecretStoreConcurrentReadError, + SecretStoreRemoveError, + SecretStoreDecodeError, + SecretStoreEncodeError, +]); +export type SecretStoreError = typeof SecretStoreError.Type; +export const isSecretStoreError = Schema.is(SecretStoreError); const isPlatformError = (value: unknown): value is PlatformError.PlatformError => Predicate.isTagged(value, "PlatformError"); export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => - isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; - -export interface ServerSecretStoreShape { - readonly get: (name: string) => Effect.Effect; - readonly set: (name: string, value: Uint8Array) => Effect.Effect; - readonly create: (name: string, value: Uint8Array) => Effect.Effect; - readonly getOrCreateRandom: ( - name: string, - bytes: number, - ) => Effect.Effect; - readonly remove: (name: string) => Effect.Effect; -} + "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; -export class ServerSecretStore extends Context.Service()( - "t3/auth/ServerSecretStore", -) {} +export class ServerSecretStore extends Context.Service< + ServerSecretStore, + { + readonly get: (name: string) => Effect.Effect, SecretStoreError>; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly create: (name: string, value: Uint8Array) => Effect.Effect; + readonly getOrCreateRandom: ( + name: string, + bytes: number, + ) => Effect.Effect; + readonly remove: (name: string) => Effect.Effect; + } +>()("t3/auth/ServerSecretStore") {} -export const make = Effect.fn("makeServerSecretStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, + new SecretStoreSecureError({ + resource: `secrets directory ${serverConfig.secretsDir}`, cause, }), ), @@ -55,15 +168,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); - const get: ServerSecretStoreShape["get"] = (name) => + const get: ServerSecretStore["Service"]["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( - Effect.map((bytes) => Uint8Array.from(bytes)), + Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" - ? Effect.succeed(null) + ? Effect.succeed(Option.none()) : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name}.`, + new SecretStoreReadError({ + resource: `secret ${name}`, cause, }), ), @@ -71,13 +184,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.get"), ); - const set: ServerSecretStoreShape["set"] = (name, value) => { + const set: ServerSecretStore["Service"]["set"] = (name, value) => { const secretPath = resolveSecretPath(name); return crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to create temporary path for secret ${name}.`, + new SecretStoreTemporaryPathError({ + resource: `secret ${name}`, cause, }), ), @@ -94,8 +207,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.ignore, Effect.flatMap(() => Effect.fail( - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + resource: `secret ${name}`, cause, }), ), @@ -108,7 +221,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ); }; - const create: ServerSecretStoreShape["create"] = (name, value) => { + const create: ServerSecretStore["Service"]["create"] = (name, value) => { const secretPath = resolveSecretPath(name); return Effect.scoped( Effect.gen(function* () { @@ -123,62 +236,64 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + resource: `secret ${name}`, cause, }), ), ); }; - const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => + const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) => get(name).pipe( - Effect.flatMap((existing) => { - if (existing) { - return Effect.succeed(existing); - } - - return crypto.randomBytes(bytes).pipe( - Effect.mapError( - (cause) => - new SecretStoreError({ - message: `Failed to generate random bytes for secret ${name}.`, - cause, - }), - ), - Effect.flatMap((generated) => - create(name, generated).pipe( - Effect.as(Uint8Array.from(generated)), - Effect.catchTag("SecretStoreError", (error) => - isSecretAlreadyExistsError(error) - ? get(name).pipe( - Effect.flatMap((created) => - created !== null - ? Effect.succeed(created) - : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name} after concurrent creation.`, - }), - ), - ), - ) - : Effect.fail(error), + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + crypto.randomBytes(bytes).pipe( + Effect.mapError( + (cause) => + new SecretStoreRandomGenerationError({ + resource: `secret ${name}`, + cause, + }), + ), + Effect.flatMap((generated) => + create(name, generated).pipe( + Effect.as(Uint8Array.from(generated)), + Effect.catchIf(isSecretStoreError, (error) => + isSecretAlreadyExistsError(error) + ? get(name).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new SecretStoreConcurrentReadError({ + resource: `secret ${name}`, + }), + ), + }), + ), + ) + : Effect.fail(error), + ), + ), ), ), - ), - ); - }), + }), + ), Effect.withSpan("ServerSecretStore.getOrCreateRandom"), ); - const remove: ServerSecretStoreShape["remove"] = (name) => + const remove: ServerSecretStore["Service"]["remove"] = (name) => fileSystem.remove(resolveSecretPath(name)).pipe( Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.void : Effect.fail( - new SecretStoreError({ - message: `Failed to remove secret ${name}.`, + new SecretStoreRemoveError({ + resource: `secret ${name}`, cause, }), ), @@ -186,13 +301,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.remove"), ); - return { + return ServerSecretStore.of({ get, set, create, getOrCreateRandom, remove, - } satisfies ServerSecretStoreShape; + }); }); -export const layer = Layer.effect(ServerSecretStore, make()); +export const layer = Layer.effect(ServerSecretStore, make); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 00abd6b9945..334c24ef52f 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -5,30 +5,29 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; -import type { ServerConfigShape } from "../config.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; -import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts"; +import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; const makeServerConfigLayer = ( - overrides?: Partial>, + overrides?: Partial>, ) => Layer.effect( - ServerConfig, + ServerConfig.ServerConfig, Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return { ...config, ...overrides, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); const makeSessionStoreLayer = ( - overrides?: Partial>, + overrides?: Partial>, ) => SessionStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), @@ -41,18 +40,18 @@ const repositoryFailure = new PersistenceSqlError({ detail: "sqlite is unavailable", }); -const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessionRepository, { +const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { create: () => Effect.void, getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), - revoke: () => Effect.succeed(false), - revokeAllExcept: () => Effect.succeed([]), + revoke: () => Effect.fail(repositoryFailure), + revokeAllExcept: () => Effect.fail(repositoryFailure), setLastConnectedAt: () => Effect.void, }); const failingSessionLookupCredentialLayer = Layer.effect( SessionStore.SessionStore, - SessionStore.make(), + SessionStore.make, ).pipe( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), @@ -90,7 +89,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessions = yield* SessionStore.SessionStore; const error = yield* Effect.flip(sessions.verify("not-a-session-token")); - expect(error._tag).toBe("SessionCredentialInvalidError"); + expect(error._tag).toBe("MalformedSessionTokenError"); expect(error.message).toContain("Malformed session token"); }).pipe(Effect.provide(makeSessionStoreLayer())), ); @@ -105,11 +104,29 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessionError = yield* Effect.flip(sessions.verify(issued.token)); const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId)); + const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId)); - expect(sessionError._tag).toBe("SessionCredentialInternalError"); - expect(websocketError._tag).toBe("SessionCredentialInternalError"); + expect(sessionError._tag).toBe("SessionCredentialVerificationError"); + expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); expect(sessionError.cause).toBe(repositoryFailure); expect(websocketError.cause).toBe(repositoryFailure); + if (sessionError._tag === "SessionCredentialVerificationError") { + expect(sessionError.sessionId).toBe(issued.sessionId); + } + if (websocketError._tag === "WebSocketTokenVerificationError") { + expect(websocketError.sessionId).toBe(issued.sessionId); + } + expect(revokeError).toMatchObject({ + _tag: "SessionRevocationError", + sessionId: issued.sessionId, + cause: repositoryFailure, + }); + expect(revokeOthersError).toMatchObject({ + _tag: "OtherSessionsRevocationError", + currentSessionId: issued.sessionId, + cause: repositoryFailure, + }); }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), ); it.effect("verifies session tokens against the Effect clock", () => @@ -146,7 +163,52 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { yield* TestClock.adjust(Duration.seconds(2)); const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); - expect(error.message).toContain("expired"); + expect(error._tag).toBe("WebSocketSessionExpiredError"); + if (error._tag === "WebSocketSessionExpiredError") { + expect(error.sessionId).toBe(issued.sessionId); + expect(error.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); + expect(error.observedAt.epochMilliseconds).toBeGreaterThan( + error.expiresAt.epochMilliseconds, + ); + } + }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), + ); + + it.effect("includes expiry context when session and websocket tokens expire", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const issued = yield* sessions.issue({ + method: "bearer-access-token", + subject: "short-lived-token", + ttl: Duration.seconds(1), + }); + const websocket = yield* sessions.issueWebSocketToken(issued.sessionId, { + ttl: Duration.seconds(1), + }); + + yield* TestClock.adjust(Duration.seconds(2)); + + const sessionError = yield* Effect.flip(sessions.verify(issued.token)); + const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + + expect(sessionError._tag).toBe("SessionTokenExpiredError"); + if (sessionError._tag === "SessionTokenExpiredError") { + expect(sessionError.sessionId).toBe(issued.sessionId); + expect(sessionError.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); + expect(sessionError.observedAt.epochMilliseconds).toBeGreaterThan( + sessionError.expiresAt.epochMilliseconds, + ); + } + expect(websocketError._tag).toBe("WebSocketTokenExpiredError"); + if (websocketError._tag === "WebSocketTokenExpiredError") { + expect(websocketError.sessionId).toBe(issued.sessionId); + expect(websocketError.expiresAt.epochMilliseconds).toBe( + websocket.expiresAt.epochMilliseconds, + ); + expect(websocketError.observedAt.epochMilliseconds).toBeGreaterThan( + websocketError.expiresAt.epochMilliseconds, + ); + } }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); @@ -174,12 +236,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { ipAddress: "192.168.1.88", }, }); + const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId); yield* sessions.markConnected(client.sessionId); const beforeRevoke = yield* sessions.listActive(); const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId); const afterRevoke = yield* sessions.listActive(); const revokedClient = yield* Effect.flip(sessions.verify(client.token)); + const revokedClientWebSocket = yield* Effect.flip( + sessions.verifyWebSocketToken(clientWebSocket.token), + ); expect(beforeRevoke).toHaveLength(2); expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( @@ -195,7 +261,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(revokedCount).toBe(1); expect(afterRevoke).toHaveLength(1); expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId); - expect(revokedClient.message).toContain("revoked"); + expect(revokedClient._tag).toBe("SessionTokenRevokedError"); + if (revokedClient._tag === "SessionTokenRevokedError") { + expect(revokedClient.sessionId).toBe(client.sessionId); + expect(revokedClient.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0); + } + expect(revokedClientWebSocket._tag).toBe("WebSocketSessionRevokedError"); + if (revokedClientWebSocket._tag === "WebSocketSessionRevokedError") { + expect(revokedClientWebSocket.sessionId).toBe(client.sessionId); + expect(revokedClientWebSocket.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0); + } }).pipe(Effect.provide(makeSessionStoreLayer())), ); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 8de145ca338..12ecb7dba4d 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -7,10 +7,8 @@ import { type AuthEnvironmentScope, type ServerAuthSessionMethod, } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -21,9 +19,8 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; -import { ServerConfig } from "../config.ts"; -import { AuthSessionRepositoryLive } from "../persistence/Layers/AuthSessions.ts"; -import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts"; +import * as ServerConfig from "../config.ts"; +import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, @@ -64,66 +61,343 @@ export type SessionCredentialChange = readonly sessionId: AuthSessionId; }; -export class SessionCredentialInvalidError extends Data.TaggedError( - "SessionCredentialInvalidError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export class SessionCredentialInternalError extends Data.TaggedError( - "SessionCredentialInternalError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError; - -export interface SessionStoreShape { - readonly cookieName: string; - readonly issue: (input?: { - readonly ttl?: Duration.Duration; - readonly subject?: string; - readonly method?: ServerAuthSessionMethod; - readonly scopes?: ReadonlyArray; - readonly client?: AuthClientMetadata; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly verify: (token: string) => Effect.Effect; - readonly issueWebSocketToken: ( +export class MalformedSessionTokenError extends Schema.TaggedErrorClass()( + "MalformedSessionTokenError", + {}, +) { + override get message(): string { + return "Malformed session token."; + } +} + +export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass()( + "InvalidSessionTokenSignatureError", + {}, +) { + override get message(): string { + return "Invalid session token signature."; + } +} + +export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass()( + "InvalidSessionTokenPayloadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid session token payload."; + } +} + +export class SessionTokenExpiredError extends Schema.TaggedErrorClass()( + "SessionTokenExpiredError", + { sessionId: AuthSessionId, - input?: { - readonly ttl?: Duration.Duration; - }, - ) => Effect.Effect< - { - readonly token: string; - readonly expiresAt: DateTime.DateTime; - }, - SessionCredentialInternalError - >; - readonly verifyWebSocketToken: ( - token: string, - ) => Effect.Effect; - readonly listActive: () => Effect.Effect< - ReadonlyArray, - SessionCredentialInternalError - >; - readonly streamChanges: Stream.Stream; - readonly revoke: ( + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, +) { + override get message(): string { + return "Session token expired."; + } +} + +export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( + "UnknownSessionTokenError", + { + sessionId: AuthSessionId, + }, +) { + override get message(): string { + return "Unknown session token."; + } +} + +export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( + "SessionTokenRevokedError", + { sessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeAllExcept: ( + revokedAt: Schema.DateTimeUtc, + }, +) { + override get message(): string { + return "Session token revoked."; + } +} + +export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass()( + "InvalidSessionExpirationClaimError", + { sessionId: AuthSessionId, - ) => Effect.Effect; - readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; - readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + expirationClaim: Schema.Number, + }, +) { + override get message(): string { + return "Invalid `exp` claim"; + } } -export class SessionStore extends Context.Service()( - "t3/auth/SessionStore", -) {} +export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass()( + "MalformedWebSocketTokenError", + {}, +) { + override get message(): string { + return "Malformed websocket token."; + } +} + +export class InvalidWebSocketTokenSignatureError extends Schema.TaggedErrorClass()( + "InvalidWebSocketTokenSignatureError", + {}, +) { + override get message(): string { + return "Invalid websocket token signature."; + } +} + +export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( + "InvalidWebSocketTokenPayloadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid websocket token payload."; + } +} + +export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass()( + "WebSocketTokenExpiredError", + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, +) { + override get message(): string { + return "Websocket token expired."; + } +} + +export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( + "UnknownWebSocketSessionError", + { + sessionId: AuthSessionId, + }, +) { + override get message(): string { + return "Unknown websocket session."; + } +} + +export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( + "WebSocketSessionExpiredError", + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, +) { + override get message(): string { + return "Websocket session expired."; + } +} + +export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass()( + "WebSocketSessionRevokedError", + { + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtc, + }, +) { + override get message(): string { + return "Websocket session revoked."; + } +} + +export const SessionCredentialInvalidError = Schema.Union([ + MalformedSessionTokenError, + InvalidSessionTokenSignatureError, + InvalidSessionTokenPayloadError, + SessionTokenExpiredError, + UnknownSessionTokenError, + SessionTokenRevokedError, + InvalidSessionExpirationClaimError, + MalformedWebSocketTokenError, + InvalidWebSocketTokenSignatureError, + InvalidWebSocketTokenPayloadError, + WebSocketTokenExpiredError, + UnknownWebSocketSessionError, + WebSocketSessionExpiredError, + WebSocketSessionRevokedError, +]); +export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type; +export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError); + +const sessionCredentialInternalErrorContext = { + cause: Schema.Defect(), +}; + +export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( + "SessionClaimsEncodingError", + { + sessionId: AuthSessionId, + operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to encode claims"; + } +} + +export class SessionCredentialIssueError extends Schema.TaggedErrorClass()( + "SessionCredentialIssueError", + { + sessionId: Schema.optional(AuthSessionId), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session credential."; + } +} + +export class SessionCredentialVerificationError extends Schema.TaggedErrorClass()( + "SessionCredentialVerificationError", + { + sessionId: AuthSessionId, + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify session credential."; + } +} + +export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "WebSocketTokenIssueError", + { + sessionId: AuthSessionId, + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass()( + "WebSocketTokenVerificationError", + { + sessionId: AuthSessionId, + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify websocket token."; + } +} + +export class ActiveSessionsListError extends Schema.TaggedErrorClass()( + "ActiveSessionsListError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list active sessions."; + } +} + +export class SessionRevocationError extends Schema.TaggedErrorClass()( + "SessionRevocationError", + { + sessionId: AuthSessionId, + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class OtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "OtherSessionsRevocationError", + { + currentSessionId: AuthSessionId, + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke other sessions."; + } +} + +export const SessionCredentialInternalError = Schema.Union([ + SessionClaimsEncodingError, + SessionCredentialIssueError, + SessionCredentialVerificationError, + WebSocketTokenIssueError, + WebSocketTokenVerificationError, + ActiveSessionsListError, + SessionRevocationError, + OtherSessionsRevocationError, +]); +export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; +export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError); + +export const SessionCredentialError = Schema.Union([ + SessionCredentialInvalidError, + SessionCredentialInternalError, +]); +export type SessionCredentialError = typeof SessionCredentialError.Type; +export const isSessionCredentialError = Schema.is(SessionCredentialError); + +export class SessionStore extends Context.Service< + SessionStore, + { + readonly cookieName: string; + readonly issue: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly method?: ServerAuthSessionMethod; + readonly scopes?: ReadonlyArray; + readonly client?: AuthClientMetadata; + readonly proofKeyThumbprint?: string; + }) => Effect.Effect; + readonly verify: (token: string) => Effect.Effect; + readonly issueWebSocketToken: ( + sessionId: AuthSessionId, + input?: { + readonly ttl?: Duration.Duration; + }, + ) => Effect.Effect< + { + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, + SessionCredentialInternalError + >; + readonly verifyWebSocketToken: ( + token: string, + ) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + SessionCredentialInternalError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeAllExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + } +>()("t3/auth/SessionStore") {} const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); @@ -185,17 +459,11 @@ function toAuthClientSession(input: Omit): AuthCli }; } -const toSessionCredentialInternalError = (message: string) => (cause: unknown) => - new SessionCredentialInternalError({ - message, - cause, - }); - -export const make = Effect.fn("makeSessionStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const secretStore = yield* ServerSecretStore.ServerSecretStore; - const authSessions = yield* AuthSessionRepository; + const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); @@ -239,7 +507,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { ); }); - const markConnected: SessionStoreShape["markConnected"] = (sessionId) => + const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) => Ref.modify(connectedSessionsRef, (current) => { const next = new Map(current); const wasDisconnected = !next.has(sessionId); @@ -273,7 +541,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { Effect.withSpan("SessionStore.markConnected"), ); - const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) => + const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); const remaining = (next.get(sessionId) ?? 0) - 1; @@ -300,9 +568,13 @@ export const make = Effect.fn("makeSessionStore")(function* () { ); const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); - const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")( + const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( function* (input) { - const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); + const sessionId = AuthSessionId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), + ), + ); const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), @@ -323,27 +595,36 @@ export const make = Effect.fn("makeSessionStore")(function* () { Effect.map(base64UrlEncode), Effect.mapError( (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), + new SessionCredentialIssueError({ + sessionId, + cause: new SessionClaimsEncodingError({ + sessionId, + operation: "encode_session_claims", + cause, + }), + }), ), ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); - yield* authSessions.create({ - sessionId, - subject: claims.sub, - scopes: claims.scopes, - method: claims.method, - client: { - label: client.label ?? null, - ipAddress: client.ipAddress ?? null, - userAgent: client.userAgent ?? null, - deviceType: client.deviceType, - os: client.os ?? null, - browser: client.browser ?? null, - }, - issuedAt, - expiresAt, - }); + yield* authSessions + .create({ + sessionId, + subject: claims.sub, + scopes: claims.scopes, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + }) + .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); yield* emitUpsert( toAuthClientSession({ sessionId, @@ -368,58 +649,54 @@ export const make = Effect.fn("makeSessionStore")(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), ); - const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")( + const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { - return yield* new SessionCredentialInvalidError({ - message: "Malformed session token.", - }); + return yield* new MalformedSessionTokenError({}); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid session token signature.", - }); + return yield* new InvalidSessionTokenSignatureError({}); } const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid session token payload.", - cause, - }), - ), + Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), ); - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Session token expired.", + const observedAt = yield* DateTime.now; + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new InvalidSessionExpirationClaimError({ + sessionId: claims.sid, + expirationClaim: claims.exp, + }); + } + if (claims.exp <= observedAt.epochMilliseconds) { + return yield* new SessionTokenExpiredError({ + sessionId: claims.sid, + expiresAt: expiresAt.value, + observedAt, }); } - const row = yield* authSessions.getById({ sessionId: claims.sid }); + const row = yield* authSessions + .getById({ sessionId: claims.sid }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }), + ), + ); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown session token.", - }); + return yield* new UnknownSessionTokenError({ sessionId: claims.sid }); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Session token revoked.", - }); - } - - const expiresAt = DateTime.make(claims.exp); - if (Option.isNone(expiresAt)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid `exp` claim", + return yield* new SessionTokenRevokedError({ + sessionId: claims.sid, + revokedAt: row.value.revokedAt, }); } @@ -434,121 +711,113 @@ export const make = Effect.fn("makeSessionStore")(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies VerifiedSession; }, - Effect.mapError((cause) => - cause._tag === "SessionCredentialInvalidError" - ? cause - : new SessionCredentialInternalError({ - message: "Failed to verify session credential.", - cause, - }), - ), ); const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); - const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn( + const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( "SessionStore.issueWebSocketToken", - )( - function* (sessionId, input) { - const issuedAt = yield* DateTime.now; - const expiresAt = DateTime.add(issuedAt, { - milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), - }); - const claims: WebSocketClaims = { - v: 1, - kind: "websocket", - sid: sessionId, - iat: issuedAt.epochMilliseconds, - exp: expiresAt.epochMilliseconds, - }; - const encodedPayload = yield* encodeWsClaims(claims).pipe( - Effect.map(base64UrlEncode), - Effect.mapError( - (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), - ), - ); - const signature = signPayload(encodedPayload, signingSecret); - return { - token: `${encodedPayload}.${signature}`, - expiresAt, - }; - }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), - ); + )(function* (sessionId, input) { + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), + }); + const claims: WebSocketClaims = { + v: 1, + kind: "websocket", + sid: sessionId, + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = yield* encodeWsClaims(claims).pipe( + Effect.map(base64UrlEncode), + Effect.mapError( + (cause) => + new WebSocketTokenIssueError({ + sessionId, + cause: new SessionClaimsEncodingError({ + sessionId, + operation: "encode_websocket_claims", + cause, + }), + }), + ), + ); + const signature = signPayload(encodedPayload, signingSecret); + return { + token: `${encodedPayload}.${signature}`, + expiresAt, + }; + }); - const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn( + const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( "SessionStore.verifyWebSocketToken", - )( - function* (token) { - const [encodedPayload, signature] = token.split("."); - if (!encodedPayload || !signature) { - return yield* new SessionCredentialInvalidError({ - message: "Malformed websocket token.", - }); - } + )(function* (token) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new MalformedWebSocketTokenError({}); + } - const expectedSignature = signPayload(encodedPayload, signingSecret); - if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid websocket token signature.", - }); - } + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new InvalidWebSocketTokenSignatureError({}); + } + + const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), + ); - const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + const observedAt = yield* DateTime.now; + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new InvalidSessionExpirationClaimError({ + sessionId: claims.sid, + expirationClaim: claims.exp, + }); + } + if (claims.exp <= observedAt.epochMilliseconds) { + return yield* new WebSocketTokenExpiredError({ + sessionId: claims.sid, + expiresAt: expiresAt.value, + observedAt, + }); + } + + const row = yield* authSessions + .getById({ sessionId: claims.sid }) + .pipe( Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid websocket token payload.", - cause, - }), + (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }), ), ); - - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket token expired.", - }); - } - - const row = yield* authSessions.getById({ sessionId: claims.sid }); - if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown websocket session.", - }); - } - if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session expired.", - }); - } - if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session revoked.", - }); - } - - return { - sessionId: row.value.sessionId, - token, - method: row.value.method, - client: toClientMetadata(row.value.client), + if (Option.isNone(row)) { + return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); + } + if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { + return yield* new WebSocketSessionExpiredError({ + sessionId: claims.sid, expiresAt: row.value.expiresAt, - subject: row.value.subject, - scopes: row.value.scopes, - } satisfies VerifiedSession; - }, - Effect.mapError((cause) => - cause._tag === "SessionCredentialInvalidError" - ? cause - : new SessionCredentialInternalError({ - message: "Failed to verify websocket token.", - cause, - }), - ), - ); + observedAt, + }); + } + if (row.value.revokedAt !== null) { + return yield* new WebSocketSessionRevokedError({ + sessionId: claims.sid, + revokedAt: row.value.revokedAt, + }); + } + + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + scopes: row.value.scopes, + } satisfies VerifiedSession; + }); - const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")( + const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( function* () { const now = yield* DateTime.now; const connectedSessions = yield* Ref.get(connectedSessionsRef); @@ -568,16 +837,18 @@ export const make = Effect.fn("makeSessionStore")(function* () { }), ); }, - Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")), + Effect.mapError((cause) => new ActiveSessionsListError({ cause })), ); - const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")( + const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( function* (sessionId) { const revokedAt = yield* DateTime.now; - const revoked = yield* authSessions.revoke({ - sessionId, - revokedAt, - }); + const revoked = yield* authSessions + .revoke({ + sessionId, + revokedAt, + }) + .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); if (revoked) { yield* Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -588,41 +859,43 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revoked; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")), ); - const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn( + const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", - )( - function* (sessionId) { - const revokedAt = yield* DateTime.now; - const revokedSessionIds = yield* authSessions.revokeAllExcept({ + )(function* (sessionId) { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions + .revokeAllExcept({ currentSessionId: sessionId, revokedAt, + }) + .pipe( + Effect.mapError( + (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), + ), + ); + if (revokedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) { + next.delete(revokedSessionId); + } + return next; }); - if (revokedSessionIds.length > 0) { - yield* Ref.update(connectedSessionsRef, (current) => { - const next = new Map(current); - for (const revokedSessionId of revokedSessionIds) { - next.delete(revokedSessionId); - } - return next; - }); - yield* Effect.forEach( - revokedSessionIds, - (revokedSessionId) => emitRemoved(revokedSessionId), - { - concurrency: "unbounded", - discard: true, - }, - ); - } - return revokedSessionIds.length; - }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")), - ); + yield* Effect.forEach( + revokedSessionIds, + (revokedSessionId) => emitRemoved(revokedSessionId), + { + concurrency: "unbounded", + discard: true, + }, + ); + } + return revokedSessionIds.length; + }); - return { + return SessionStore.of({ cookieName, issue, verify, @@ -636,9 +909,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { revokeAllExcept, markConnected, markDisconnected, - } satisfies SessionStoreShape; + }); }); -export const layer = Layer.effect(SessionStore, make()).pipe( - Layer.provideMerge(AuthSessionRepositoryLive), -); +export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer)); diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 76898bc9463..fa75c407b0c 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; -import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { SecretStorePersistError } from "./ServerSecretStore.ts"; import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist DPoP proof.", + new SecretStorePersistError({ + resource: "DPoP proof", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -17,16 +17,20 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => describe("mapDpopReplayStoreError", () => { it("reports replay conflicts as invalid credentials", () => { - const error = mapDpopReplayStoreError(storeFailure("AlreadyExists")); + const cause = storeFailure("AlreadyExists"); + const error = mapDpopReplayStoreError(cause); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + if (error._tag === "ServerAuthInvalidCredentialError") { + expect(error.cause).toBe(cause); + } }); it("reports replay-store availability failures as internal errors", () => { const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); - expect(error._tag).toBe("ServerAuthInternalError"); - if (error._tag === "ServerAuthInternalError") { + expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); + if (error._tag === "ServerAuthDpopReplayStateRecordError") { expect(error.message).toBe("Failed to record DPoP proof replay state."); } }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 66cd07f9e2e..f19984eb369 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -3,37 +3,26 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; -import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Option from "effect/Option"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { + ServerAuthDpopReplayKeyCalculationError, + ServerAuthDpopReplayStateRecordError, + ServerAuthInvalidCredentialError, + type ServerAuthInternalError, +} from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -function firstHeaderValue(value: string | undefined): string | undefined { - const first = value?.split(",")[0]?.trim(); - return first && first.length > 0 ? first : undefined; -} - -export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string { - try { - return new URL(request.originalUrl).href; - } catch { - const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1"; - const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]); - const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http"; - return new URL(request.originalUrl, `${proto}://${host}`).href; - } -} - export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, -): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError => +): ServerAuthInvalidCredentialError | ServerAuthInternalError => ServerSecretStore.isSecretAlreadyExistsError(error) - ? new EnvironmentAuth.ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP proof replayed.", + ? new ServerAuthInvalidCredentialError({ + diagnostic: "DPoP proof replayed.", + cause: error, }) - : new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to record DPoP proof replay state.", + : new ServerAuthDpopReplayStateRecordError({ cause: error, }); @@ -44,19 +33,24 @@ export const verifyRequestDpopProof = (input: { }) => Effect.gen(function* () { const proof = input.request.headers.dpop; + const url = HttpServerRequest.toURL(input.request); + if (Option.isNone(url)) { + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: "Invalid DPoP request URL.", + }); + } const now = yield* DateTime.now; const result = verifyDpopProof({ proof, method: input.request.method, - url: requestAbsoluteUrl(input.request), + url: url.value.href, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), }); if (!result.ok) { - return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: result.reason, + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: result.reason, }); } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -67,8 +61,7 @@ export const verifyRequestDpopProof = (input: { Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to calculate DPoP replay key.", + new ServerAuthDpopReplayKeyCalculationError({ cause, }), ), @@ -86,7 +79,9 @@ export const verifyRequestDpopProof = (input: { ), ) .pipe( - Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))), + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => + Effect.fail(mapDpopReplayStoreError(error)), + ), ); return result.thumbprint; }); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index ed640863d21..71fb00b970a 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -25,6 +25,7 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Cookies from "effect/unstable/http/Cookies"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; @@ -33,6 +34,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; +import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -167,16 +169,19 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); return yield* httpEffect.pipe( Effect.provideService(EnvironmentAuthenticatedPrincipal, { ...session, scopes: new Set(session.scopes), }), + session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity, ); }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); }), @@ -198,7 +203,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const request = yield* HttpServerRequest.HttpServerRequest; return yield* serverAuth.getSessionState(request); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), ), ), @@ -228,11 +233,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return result.response; }, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("browser_session_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("browser_session_issuance_failed", error), + ), ), ) .handle( @@ -262,14 +268,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: () => - appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), - ), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + appendDpopChallengeHeader.pipe( + Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), ) : undefined; yield* appendCredentialResponseHeaders; @@ -289,12 +295,16 @@ export const authHttpApiLayer = HttpApiBuilder.group( proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); }, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + traceRelayRequest, + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => + failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), ), ) .handle( @@ -306,7 +316,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return yield* serverAuth.issueWebSocketTicket(session); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("websocket_ticket_issuance_failed", error), ), ), @@ -331,7 +341,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( } return yield* serverAuth.issuePairingCredential(args.payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_credential_issuance_failed", error), ), ), @@ -344,7 +354,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listPairingLinks(); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_links_load_failed", error), ), ), @@ -358,7 +368,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revoked = yield* serverAuth.revokePairingLink(args.payload.id); return { revoked }; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_link_revoke_failed", error), ), ), @@ -371,7 +381,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const session = yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listClientSessions(session.sessionId); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_sessions_load_failed", error), ), ), @@ -388,12 +398,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); return { revoked }; }, - Effect.catchTags({ - ServerAuthForbiddenOperationError: (error) => - failEnvironmentOperationForbidden(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("client_session_revoke_failed", error), - }), + Effect.catchTag("ServerAuthForbiddenOperationError", () => + failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + ), ), ) .handle( @@ -405,7 +415,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); return { revokedCount }; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_session_revoke_failed", error), ), ), diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 7260ac7c54d..39f04988ac5 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -4,7 +4,7 @@ import type { AuthClientPresentationMetadata, } from "@t3tools/contracts"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import * as Crypto from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import * as Encoding from "effect/Encoding"; import * as Result from "effect/Result"; @@ -32,7 +32,7 @@ export function base64UrlDecodeUtf8(input: string): string { } export function signPayload(payload: string, secret: Uint8Array): string { - return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); + return NodeCrypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); } export function timingSafeEqualBase64Url(left: string, right: string): boolean { @@ -41,7 +41,7 @@ export function timingSafeEqualBase64Url(left: string, right: string): boolean { if (leftBuffer.length !== rightBuffer.length) { return false; } - return Crypto.timingSafeEqual(leftBuffer, rightBuffer); + return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer); } function normalizeNonEmptyString(value: string | null | undefined): string | undefined { diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 9ff014df70c..e5e6c18c1e6 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. import * as NodeHttp from "node:http"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -21,17 +21,17 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; -import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerConfig from "./config.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import { makePersistedServerRuntimeState, persistServerRuntimeState, } from "./serverRuntimeState.ts"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; @@ -58,7 +58,7 @@ const captureStdout = (effect: Effect.Effect) => const makeCliTestServerConfig = (baseDir: string) => Effect.gen(function* () { - const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); return { logLevel: "Info", traceMinLevel: "Info", @@ -86,26 +86,23 @@ const makeCliTestServerConfig = (baseDir: string) => basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }); -const makeProjectPersistenceLayer = (config: ServerConfigShape) => +const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service"]) => Layer.mergeAll( OrchestrationLayerLive.pipe( - Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceLayerLive), ), - WorkspacePathsLive, - ).pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(ServerConfig, config)), - ); + WorkspacePaths.layer, + ).pipe(Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config))); const readPersistedSnapshot = (baseDir: string) => Effect.gen(function* () { const config = yield* makeCliTestServerConfig(baseDir); return yield* Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; return yield* projectionSnapshotQuery.getSnapshot(); }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); }); @@ -135,7 +132,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef }), ), Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(ServerConfig.layer(config)), ); return yield* Effect.scoped( @@ -202,7 +199,9 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("reports fresh headless connect state without requiring local configuration", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-test-")); + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-status-test-"), + ); const { output } = yield* captureStdout( runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]), ); @@ -225,7 +224,9 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("reports actionable human-readable headless connect state", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-human-test-")); + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-status-human-test-"), + ); const { output } = yield* captureStdout( runConnectCli(["connect", "status", "--base-dir", baseDir]), ); @@ -239,11 +240,13 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("logs in to headless connect without enabling access", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-login-test-")); - const { secretsDir } = yield* deriveServerPaths(baseDir, undefined); - mkdirSync(secretsDir, { recursive: true }); - writeFileSync( - join(secretsDir, "cloud-cli-oauth-token.bin"), + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-login-test-"), + ); + const { secretsDir } = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + NodeFS.mkdirSync(secretsDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(secretsDir, "cloud-cli-oauth-token.bin"), // @effect-diagnostics-next-line preferSchemaOverJson:off - Test fixture matches the persisted CLI token representation. JSON.stringify({ accessToken: "access-token", @@ -272,7 +275,9 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("disables headless connect without a running server", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-unlink-test-")); + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-unlink-test-"), + ); const { output } = yield* captureStdout( runConnectCli(["connect", "unlink", "--base-dir", baseDir]), ); @@ -283,24 +288,28 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("logs out of headless connect and removes the stored CLI authorization", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-logout-test-")); - const { secretsDir } = yield* deriveServerPaths(baseDir, undefined); - const tokenPath = join(secretsDir, "cloud-cli-oauth-token.bin"); - mkdirSync(secretsDir, { recursive: true }); - writeFileSync(tokenPath, "invalid persisted token"); + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-logout-test-"), + ); + const { secretsDir } = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + const tokenPath = NodePath.join(secretsDir, "cloud-cli-oauth-token.bin"); + NodeFS.mkdirSync(secretsDir, { recursive: true }); + NodeFS.writeFileSync(tokenPath, "invalid persisted token"); const { output } = yield* captureStdout( runConnectCli(["connect", "logout", "--base-dir", baseDir]), ); assert.equal(output, "Signed out of T3 Connect locally."); - assert.isFalse(existsSync(tokenPath)); + assert.isFalse(NodeFS.existsSync(tokenPath)); }), ); it.effect("executes auth pairing subcommands and redacts secrets from list output", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-pairing-test-"), + ); const createdOutput = yield* captureStdout( runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]), @@ -330,7 +339,9 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("executes auth session subcommands and redacts secrets from list output", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-")); + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-session-test-"), + ); const issuedOutput = yield* captureStdout( runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), @@ -405,8 +416,12 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("adds, renames, and removes projects offline through the orchestration engine", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-")); - const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-")); + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-offline-test-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-workspace-"), + ); yield* runCliWithRuntime([ "project", @@ -449,8 +464,12 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("routes project commands through a running server when runtime state is present", () => Effect.gen(function* () { - const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-")); - const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-")); + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-live-test-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-live-workspace-"), + ); yield* withLiveProjectCliServer(baseDir, () => Effect.gen(function* () { @@ -463,7 +482,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { "--base-dir", baseDir, ]); - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const readModel = yield* projectionSnapshotQuery.getSnapshot(); const addedProject = readModel.projects.find( (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, @@ -477,8 +496,8 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("rejects dev-url on project commands", () => Effect.gen(function* () { - const workspaceRoot = mkdtempSync( - join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"), + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-unknown-option-workspace-"), ); const error = yield* runCliWithRuntime([ "project", diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index a3bbcc66d34..05155f32ec4 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -1,9 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NFS from "node:fs"; -import * as path from "node:path"; -import { execFileSync, spawn } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Duration from "effect/Duration"; @@ -13,10 +13,19 @@ import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { readBootstrapEnvelope } from "./bootstrap.ts"; +import { + BootstrapEnvelopeDecodeError, + BootstrapFdStatError, + BootstrapInputStreamOpenError, + readBootstrapEnvelope, +} from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; -const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); +const openSyncInterceptor = vi.hoisted(() => ({ + failPath: null as string | null, + errorCode: "ENXIO", +})); +const fstatSyncInterceptor = vi.hoisted(() => ({ failFd: null as number | null })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); @@ -29,12 +38,20 @@ vi.mock("node:fs", async (importOriginal) => { filePath === openSyncInterceptor.failPath && flags === "r" ) { - const error = new Error("no such device or address"); - Object.assign(error, { code: "ENXIO" }); + const error = new Error(`open failed with ${openSyncInterceptor.errorCode}`); + Object.assign(error, { code: openSyncInterceptor.errorCode }); throw error; } return (actual.openSync as (...a: typeof args) => number)(...args); }, + fstatSync: (...args: Parameters) => { + if (args[0] === fstatSyncInterceptor.failFd) { + const error = new Error("permission denied"); + Object.assign(error, { code: "EACCES" }); + throw error; + } + return (actual.fstatSync as (...a: typeof args) => NodeFS.Stats)(...args); + }, }; }); @@ -53,8 +70,8 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { ); const fd = yield* Effect.acquireRelease( - Effect.sync(() => NFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => NFS.closeSync(fd)), + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), ); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); @@ -78,7 +95,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { // so the stream owns the fd lifecycle and closes it asynchronously on end. // Attempting to also close it synchronously in a finalizer races with the // stream's async close and produces an uncaught EBADF. - const fd = NFS.openSync(filePath, "r"); + const fd = NodeFS.openSync(filePath, "r"); openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; try { @@ -94,27 +111,107 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + it.effect("preserves fd path, platform, and cause when opening the input stream fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + const fdPath = `/proc/self/fd/${fd}`; + + openSyncInterceptor.failPath = fdPath; + openSyncInterceptor.errorCode = "EIO"; + try { + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.provideService(HostProcessPlatform, "linux"), Effect.flip); + + assert.instanceOf(error, BootstrapInputStreamOpenError); + assert.equal(error.fd, fd); + assert.equal(error.platform, "linux"); + assert.equal(error.fdPath, fdPath); + assert.equal((error.cause as NodeJS.ErrnoException).code, "EIO"); + assert.equal( + error.message, + `Failed to open bootstrap input stream for file descriptor ${fd} via '${fdPath}' on 'linux'.`, + ); + } finally { + openSyncInterceptor.failPath = null; + openSyncInterceptor.errorCode = "ENXIO"; + } + }), + ); + it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { - const fd = NFS.openSync("/dev/null", "r"); - NFS.closeSync(fd); + const fd = NodeFS.openSync("/dev/null", "r"); + NodeFS.closeSync(fd); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); assertNone(payload); }), ); + it.effect("preserves fd and cause when stat fails for a non-availability reason", () => + Effect.gen(function* () { + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync("/dev/null", "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + + fstatSyncInterceptor.failFd = fd; + try { + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.flip); + + assert.instanceOf(error, BootstrapFdStatError); + assert.equal(error.fd, fd); + assert.equal((error.cause as NodeJS.ErrnoException).code, "EACCES"); + assert.equal(error.message, `Failed to stat bootstrap file descriptor ${fd}.`); + } finally { + fstatSyncInterceptor.failFd = null; + } + }), + ); + + it.effect("preserves fd and schema cause when decoding the envelope fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + yield* fs.writeFileString(filePath, '{"mode":42}\n'); + + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.flip); + + assert.instanceOf(error, BootstrapEnvelopeDecodeError); + assert.equal(error.fd, fd); + assert.isDefined(error.cause); + assert.equal( + error.message, + `Failed to decode bootstrap envelope from file descriptor ${fd}.`, + ); + }), + ); + it.effect("returns none when the bootstrap read times out before any value arrives", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); - const fifoPath = path.join(tempDir, "bootstrap.pipe"); + const fifoPath = NodePath.join(tempDir, "bootstrap.pipe"); - yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); + yield* Effect.sync(() => NodeChildProcess.execFileSync("mkfifo", [fifoPath])); const _writer = yield* Effect.acquireRelease( Effect.sync(() => - spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { + NodeChildProcess.spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { stdio: ["ignore", "ignore", "ignore"], }), ), @@ -125,8 +222,8 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { ); const fd = yield* Effect.acquireRelease( - Effect.sync(() => NFS.openSync(fifoPath, "r")), - (fd) => Effect.sync(() => NFS.closeSync(fd)), + Effect.sync(() => NodeFS.openSync(fifoPath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), ); const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 83d1d337888..0f2a5a436a3 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -1,10 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NFS from "node:fs"; -import * as Net from "node:net"; -import * as readline from "node:readline"; -import type { Readable } from "node:stream"; +import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; +import * as NodeReadline from "node:readline"; +import type * as NodeStream from "node:stream"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; @@ -13,10 +12,64 @@ import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -class BootstrapError extends Data.TaggedError("BootstrapError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class BootstrapFdStatError extends Schema.TaggedErrorClass()( + "BootstrapFdStatError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to stat bootstrap file descriptor ${this.fd}.`; + } +} + +export class BootstrapInputStreamOpenError extends Schema.TaggedErrorClass()( + "BootstrapInputStreamOpenError", + { + fd: Schema.Number, + platform: Schema.String, + fdPath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const path = this.fdPath === undefined ? "" : ` via '${this.fdPath}'`; + return `Failed to open bootstrap input stream for file descriptor ${this.fd}${path} on '${this.platform}'.`; + } +} + +export class BootstrapEnvelopeReadError extends Schema.TaggedErrorClass()( + "BootstrapEnvelopeReadError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read bootstrap envelope from file descriptor ${this.fd}.`; + } +} + +export class BootstrapEnvelopeDecodeError extends Schema.TaggedErrorClass()( + "BootstrapEnvelopeDecodeError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode bootstrap envelope from file descriptor ${this.fd}.`; + } +} + +export const BootstrapError = Schema.Union([ + BootstrapFdStatError, + BootstrapInputStreamOpenError, + BootstrapEnvelopeReadError, + BootstrapEnvelopeDecodeError, +]); +export type BootstrapError = typeof BootstrapError.Type; export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* ( schema: Schema.Codec, @@ -32,8 +85,11 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function const timeoutMs = options?.timeoutMs ?? 1000; - return yield* Effect.callback, BootstrapError>((resume) => { - const input = readline.createInterface({ + return yield* Effect.callback< + Option.Option, + BootstrapEnvelopeReadError | BootstrapEnvelopeDecodeError + >((resume) => { + const input = NodeReadline.createInterface({ input: stream, crlfDelay: Infinity, }); @@ -53,8 +109,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } resume( Effect.fail( - new BootstrapError({ - message: "Failed to read bootstrap envelope.", + new BootstrapEnvelopeReadError({ + fd, cause: error, }), ), @@ -68,8 +124,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } else { resume( Effect.fail( - new BootstrapError({ - message: "Failed to decode bootstrap envelope.", + new BootstrapEnvelopeDecodeError({ + fd, cause: parsed.failure, }), ), @@ -96,34 +152,34 @@ const isUnavailableBootstrapFdError = Predicate.compose( const isFdReady = (fd: number) => Effect.try({ - try: () => NFS.fstatSync(fd), + try: () => NodeFS.fstatSync(fd), catch: (error) => - new BootstrapError({ - message: "Failed to stat bootstrap fd.", + new BootstrapFdStatError({ + fd, cause: error, }), }).pipe( Effect.as(true), - Effect.catchIf( - (error) => isUnavailableBootstrapFdError(error.cause), - () => Effect.succeed(false), - ), + Effect.catchTags({ + BootstrapFdStatError: (error) => + isUnavailableBootstrapFdError(error.cause) ? Effect.succeed(false) : Effect.fail(error), + }), ); const makeBootstrapInputStream = (fd: number) => Effect.gen(function* () { const platform = yield* HostProcessPlatform; - return yield* Effect.try({ + const fdPath = resolveFdPath(fd, platform); + return yield* Effect.try({ try: () => { - const fdPath = resolveFdPath(fd, platform); if (fdPath === undefined) { return makeDirectBootstrapStream(fd); } let streamFd: number | undefined; try { - streamFd = NFS.openSync(fdPath, "r"); - return NFS.createReadStream("", { + streamFd = NodeFS.openSync(fdPath, "r"); + return NodeFS.createReadStream("", { fd: streamFd, encoding: "utf8", autoClose: true, @@ -131,7 +187,7 @@ const makeBootstrapInputStream = (fd: number) => } catch (error) { if (isBootstrapFdPathDuplicationError(error)) { if (streamFd !== undefined) { - NFS.closeSync(streamFd); + NodeFS.closeSync(streamFd); } return makeDirectBootstrapStream(fd); } @@ -139,22 +195,24 @@ const makeBootstrapInputStream = (fd: number) => } }, catch: (error) => - new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", + new BootstrapInputStreamOpenError({ + fd, + platform, + ...(fdPath === undefined ? {} : { fdPath }), cause: error, }), }); }); -const makeDirectBootstrapStream = (fd: number): Readable => { +const makeDirectBootstrapStream = (fd: number): NodeStream.Readable => { try { - return NFS.createReadStream("", { + return NodeFS.createReadStream("", { fd, encoding: "utf8", autoClose: true, }); } catch { - const stream = new Net.Socket({ + const stream = new NodeNet.Socket({ fd, readable: true, writable: false, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts new file mode 100644 index 00000000000..c1dbc833718 --- /dev/null +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -0,0 +1,426 @@ +import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { describe, expect } from "vite-plus/test"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { checkpointRefForThreadTurn } from "./Utils.ts"; +import * as CheckpointDiffQuery from "./CheckpointDiffQuery.ts"; +import * as CheckpointStore from "./CheckpointStore.ts"; +import { CheckpointThreadNotFoundError } from "./Errors.ts"; + +function makeThreadCheckpointContext(input: { + readonly projectId: ProjectId; + readonly threadId: ThreadId; + readonly workspaceRoot: string; + readonly worktreePath: string | null; + readonly checkpointTurnCount: number; + readonly checkpointRef: CheckpointRef; +}): ProjectionSnapshotQuery.ProjectionThreadCheckpointContext { + return { + threadId: input.threadId, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + worktreePath: input.worktreePath, + checkpoints: [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: input.checkpointTurnCount, + checkpointRef: input.checkpointRef, + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }; +} + +describe("CheckpointDiffQuery.layer", () => { + it.effect("uses the narrow full-thread context lookup for all-turns diffs", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-full-thread"); + const threadId = ThreadId.make("thread-full-thread"); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 4); + let getThreadCheckpointContextCalls = 0; + let getFullThreadDiffContextCalls = 0; + const diffCheckpointsCalls: Array<{ + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; + readonly cwd: string; + readonly ignoreWhitespace: boolean; + }> = []; + + const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => + Effect.sync(() => { + diffCheckpointsCalls.push({ + fromCheckpointRef, + toCheckpointRef, + cwd, + ignoreWhitespace, + }); + return "full thread diff patch"; + }), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQuery.layer.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => + Effect.sync(() => { + getThreadCheckpointContextCalls += 1; + return Option.none(); + }), + getFullThreadDiffContext: () => + Effect.sync(() => { + getFullThreadDiffContextCalls += 1; + return Option.some({ + threadId, + projectId, + workspaceRoot: "/tmp/workspace", + worktreePath: "/tmp/worktree", + latestCheckpointTurnCount: 4, + toCheckpointRef, + }); + }), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + const result = yield* Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + return yield* query.getFullThreadDiff({ + threadId, + toTurnCount: 4, + ignoreWhitespace: true, + }); + }).pipe(Effect.provide(layer)); + + expect(getThreadCheckpointContextCalls).toBe(0); + expect(getFullThreadDiffContextCalls).toBe(1); + expect(diffCheckpointsCalls).toEqual([ + { + cwd: "/tmp/worktree", + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef, + ignoreWhitespace: true, + }, + ]); + expect(result).toEqual({ + threadId, + fromTurnCount: 0, + toTurnCount: 4, + diff: "full thread diff patch", + }); + }), + ); + + it.effect("computes diffs using canonical turn-0 checkpoint refs", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-1"); + const threadId = ThreadId.make("thread-1"); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + const diffCheckpointsCalls: Array<{ + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; + readonly cwd: string; + readonly ignoreWhitespace: boolean; + }> = []; + + const threadCheckpointContext = makeThreadCheckpointContext({ + projectId, + threadId, + workspaceRoot: "/tmp/workspace", + worktreePath: null, + checkpointTurnCount: 1, + checkpointRef: toCheckpointRef, + }); + + const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => + Effect.sync(() => { + diffCheckpointsCalls.push({ + fromCheckpointRef, + toCheckpointRef, + cwd, + ignoreWhitespace, + }); + return "diff patch"; + }), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQuery.layer.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + const result = yield* Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + return yield* query.getTurnDiff({ + threadId, + fromTurnCount: 0, + toTurnCount: 1, + ignoreWhitespace: true, + }); + }).pipe(Effect.provide(layer)); + + const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); + expect(diffCheckpointsCalls).toEqual([ + { + cwd: "/tmp/workspace", + fromCheckpointRef: expectedFromRef, + toCheckpointRef, + ignoreWhitespace: true, + }, + ]); + expect(result).toEqual({ + threadId, + fromTurnCount: 0, + toTurnCount: 1, + diff: "diff patch", + }); + }), + ); + + it.effect("defaults to hide whitespace changes", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-default-whitespace"); + const threadId = ThreadId.make("thread-default-whitespace"); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = []; + + const threadCheckpointContext = makeThreadCheckpointContext({ + projectId, + threadId, + workspaceRoot: "/tmp/workspace", + worktreePath: null, + checkpointTurnCount: 1, + checkpointRef: toCheckpointRef, + }); + + const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: ({ ignoreWhitespace }) => + Effect.sync(() => { + diffCheckpointsCalls.push({ ignoreWhitespace }); + return "diff patch"; + }), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQuery.layer.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + yield* Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + return yield* query.getTurnDiff({ + threadId, + fromTurnCount: 0, + toTurnCount: 1, + }); + }).pipe(Effect.provide(layer)); + + expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); + }), + ); + + it.effect("does not preflight checkpoint refs before diffing", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-no-preflight"); + const threadId = ThreadId.make("thread-no-preflight"); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + let hasCheckpointRefCallCount = 0; + + const threadCheckpointContext = makeThreadCheckpointContext({ + projectId, + threadId, + workspaceRoot: "/tmp/workspace", + worktreePath: null, + checkpointTurnCount: 1, + checkpointRef: toCheckpointRef, + }); + + const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => + Effect.sync(() => { + hasCheckpointRefCallCount += 1; + return true; + }), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: () => Effect.succeed("diff patch"), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQuery.layer.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + yield* Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + return yield* query.getTurnDiff({ + threadId, + fromTurnCount: 0, + toTurnCount: 1, + ignoreWhitespace: true, + }); + }).pipe(Effect.provide(layer)); + + expect(hasCheckpointRefCallCount).toBe(0); + }), + ); + + it.effect("fails when the thread is missing from the snapshot", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-missing"); + + const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: () => Effect.succeed(""), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQuery.layer.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + const error = yield* Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + return yield* query.getTurnDiff({ + threadId, + fromTurnCount: 0, + toTurnCount: 1, + }); + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(CheckpointThreadNotFoundError); + expect(error).toMatchObject({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + }); + expect(error.message).toBe( + "Checkpoint invariant violation in CheckpointDiffQuery.getTurnDiff: Thread 'thread-missing' not found.", + ); + }), + ); +}); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts similarity index 62% rename from apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts rename to apps/server/src/checkpointing/CheckpointDiffQuery.ts index b07c06ac936..077506ff3a8 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -1,23 +1,61 @@ +/** + * CheckpointDiffQuery - Query interface for computed checkpoint diffs. + * + * Provides read-only diff operations across checkpoint snapshots used by + * orchestration APIs. + * + * @module CheckpointDiffQuery + */ import { type CheckpointRef, OrchestrationGetTurnDiffResult, - type ThreadId, + type OrchestrationGetFullThreadDiffInput, type OrchestrationGetFullThreadDiffResult, + type OrchestrationGetTurnDiffInput, type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, + type ThreadId, } from "@t3tools/contracts"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; -import { checkpointRefForThreadTurn } from "../Utils.ts"; -import { CheckpointStore } from "../Services/CheckpointStore.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { + CheckpointDiffResultInvalidError, + CheckpointRefUnavailableError, + CheckpointThreadNotFoundError, + CheckpointTurnRangeUnavailableError, + CheckpointWorkspacePathMissingError, +} from "./Errors.ts"; +import type { CheckpointServiceError } from "./Errors.ts"; +import { checkpointRefForThreadTurn } from "./Utils.ts"; +import * as CheckpointStore from "./CheckpointStore.ts"; + +/** Service tag for checkpoint diff queries. */ +export class CheckpointDiffQuery extends Context.Service< CheckpointDiffQuery, - type CheckpointDiffQueryShape, -} from "../Services/CheckpointDiffQuery.ts"; + { + /** + * Read the patch diff for a single turn checkpoint transition. + * + * Verifies checkpoint availability in both projection state and filesystem. + */ + readonly getTurnDiff: ( + input: OrchestrationGetTurnDiffInput, + ) => Effect.Effect; + + /** + * Read the full patch diff across a thread range of checkpoints. + * + * Uses turn-diff semantics with `fromTurnCount = 0`. + */ + readonly getFullThreadDiff: ( + input: OrchestrationGetFullThreadDiffInput, + ) => Effect.Effect; + } +>()("t3/checkpointing/CheckpointDiffQuery") {} const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); @@ -37,11 +75,11 @@ function buildTurnDiffResult( }; } -const make = Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const checkpointStore = yield* CheckpointStore; +export const make = Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const checkpointStore = yield* CheckpointStore.CheckpointStore; - const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")( + const getTurnDiff: CheckpointDiffQuery["Service"]["getTurnDiff"] = Effect.fn("getTurnDiff")( function* (input) { const operation = "CheckpointDiffQuery.getTurnDiff"; const ignoreWhitespace = input.ignoreWhitespace ?? true; @@ -60,9 +98,9 @@ const make = Effect.gen(function* () { diff: "", }; if (!isTurnDiffResult(emptyDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed turn diff result does not satisfy contract schema.", + threadId: input.threadId, }); } return emptyDiff; @@ -72,9 +110,9 @@ const make = Effect.gen(function* () { .getThreadCheckpointContext(input.threadId) .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); if (Option.isNone(threadContext)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointThreadNotFoundError({ operation, - detail: `Thread '${input.threadId}' not found.`, + threadId: input.threadId, }); } @@ -83,18 +121,19 @@ const make = Effect.gen(function* () { 0, ); if (input.toTurnCount > maxTurnCount) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointTurnRangeUnavailableError({ + operation, threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, + requestedTurnCount: input.toTurnCount, + availableTurnCount: maxTurnCount, }); } const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; if (!workspaceCwd) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointWorkspacePathMissingError({ operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, + threadId: input.threadId, }); } @@ -105,10 +144,11 @@ const make = Effect.gen(function* () { (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, )?.checkpointRef; if (!fromCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.fromTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, + checkpoint: "from", }); } @@ -116,10 +156,11 @@ const make = Effect.gen(function* () { (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, )?.checkpointRef; if (!toCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.toTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, + checkpoint: "to", }); } @@ -135,9 +176,9 @@ const make = Effect.gen(function* () { const turnDiff = buildTurnDiffResult(input, diff); if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed turn diff result does not satisfy contract schema.", + threadId: input.threadId, }); } @@ -145,7 +186,7 @@ const make = Effect.gen(function* () { }, ); - const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = Effect.fn( + const getFullThreadDiff: CheckpointDiffQuery["Service"]["getFullThreadDiff"] = Effect.fn( "CheckpointDiffQuery.getFullThreadDiff", )(function* (input) { const operation = "CheckpointDiffQuery.getFullThreadDiff"; @@ -168,9 +209,9 @@ const make = Effect.gen(function* () { "", ); if (!isTurnDiffResult(emptyDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed full thread diff result does not satisfy contract schema.", + threadId: input.threadId, }); } return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; @@ -181,33 +222,35 @@ const make = Effect.gen(function* () { .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); if (Option.isNone(threadContext)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointThreadNotFoundError({ operation, - detail: `Thread '${input.threadId}' not found.`, + threadId: input.threadId, }); } if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointTurnRangeUnavailableError({ + operation, threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, + requestedTurnCount: input.toTurnCount, + availableTurnCount: threadContext.value.latestCheckpointTurnCount, }); } const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; if (!workspaceCwd) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointWorkspacePathMissingError({ operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, + threadId: input.threadId, }); } if (!threadContext.value.toCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.toTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, + checkpoint: "to", }); } @@ -230,19 +273,19 @@ const make = Effect.gen(function* () { diff, ); if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed full thread diff result does not satisfy contract schema.", + threadId: input.threadId, }); } return turnDiff satisfies OrchestrationGetFullThreadDiffResult; }); - return { + return CheckpointDiffQuery.of({ getTurnDiff, getFullThreadDiff, - } satisfies CheckpointDiffQueryShape; + }); }); -export const CheckpointDiffQueryLive = Layer.effect(CheckpointDiffQuery, make); +export const layer = Layer.effect(CheckpointDiffQuery, make); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts similarity index 79% rename from apps/server/src/checkpointing/Layers/CheckpointStore.test.ts rename to apps/server/src/checkpointing/CheckpointStore.test.ts index 778956e5206..bf332d20d0d 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -1,8 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off -import path from "node:path"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import { ThreadId, type VcsError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -10,21 +11,18 @@ import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import { describe, expect } from "vite-plus/test"; -import { checkpointRefForThreadTurn } from "../Utils.ts"; -import { CheckpointStoreLive } from "./CheckpointStore.ts"; -import { CheckpointStore } from "../Services/CheckpointStore.ts"; -import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import type { VcsError } from "@t3tools/contracts"; -import { ServerConfig } from "../../config.ts"; -import { ThreadId } from "@t3tools/contracts"; +import { checkpointRefForThreadTurn } from "./Utils.ts"; +import * as CheckpointStore from "./CheckpointStore.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ServerConfig from "../config.ts"; -const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { +const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); -const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( +const CheckpointStoreTestLayer = CheckpointStore.layer.pipe( Layer.provideMerge(VcsDriverTestLayer), Layer.provideMerge(NodeServices.layer), ); @@ -82,7 +80,7 @@ function initRepoWithCommit( yield* git(cwd, ["init"]); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); + yield* writeTextFile(NodePath.join(cwd, "README.md"), "# test\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "initial commit"]); }); @@ -94,13 +92,34 @@ function buildLargeText(lineCount = 5_000): string { .concat("\n"); } -it.layer(TestLayer)("CheckpointStoreLive", (it) => { +it.layer(TestLayer)("CheckpointStore.layer", (it) => { + describe("isGitRepository", () => { + it.effect("returns false when no Git repository is detected", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + + expect(yield* checkpointStore.isGitRepository(tmp)).toBe(false); + }), + ); + + it.effect("returns true when a Git repository is detected", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + + expect(yield* checkpointStore.isGitRepository(tmp)).toBe(true); + }), + ); + }); + describe("diffCheckpoints", () => { it.effect("returns full oversized checkpoint diffs without truncation", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const checkpointStore = yield* CheckpointStore; + const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); @@ -109,7 +128,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { cwd: tmp, checkpointRef: fromCheckpointRef, }); - yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); + yield* writeTextFile(NodePath.join(tmp, "README.md"), buildLargeText()); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef, @@ -132,12 +151,12 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const checkpointStore = yield* CheckpointStore; + const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const componentPath = path.join(tmp, "Component.tsx"); + const componentPath = NodePath.join(tmp, "Component.tsx"); yield* writeTextFile( componentPath, [ diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts new file mode 100644 index 00000000000..f13aa4572c1 --- /dev/null +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -0,0 +1,170 @@ +/** + * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. + * + * Owns hidden Git-ref checkpoint capture/restore and diff computation for a + * workspace thread timeline. It does not store user-facing checkpoint metadata + * and does not coordinate provider conversation rollback. + * + * The live adapter resolves the active VCS driver once per checkpoint operation + * and delegates to the driver's optional checkpoint capability. + * + * Uses Effect `Context.Service` for dependency injection and exposes typed + * domain errors for checkpoint storage operations. + * + * @module CheckpointStore + */ +import { VcsUnsupportedOperationError, type CheckpointRef } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import type { CheckpointStoreError } from "./Errors.ts"; +import type { VcsCheckpointOps } from "../vcs/VcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; + +export interface CaptureCheckpointInput { + readonly cwd: string; + readonly checkpointRef: CheckpointRef; +} + +export interface RestoreCheckpointInput { + readonly cwd: string; + readonly checkpointRef: CheckpointRef; + readonly fallbackToHead?: boolean; +} + +export interface DiffCheckpointsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; + readonly fallbackFromToHead?: boolean; + readonly ignoreWhitespace: boolean; +} + +export interface DeleteCheckpointRefsInput { + readonly cwd: string; + readonly checkpointRefs: ReadonlyArray; +} + +/** Service tag for checkpoint persistence and restore operations. */ +export class CheckpointStore extends Context.Service< + CheckpointStore, + { + /** Check whether cwd is inside a Git worktree. */ + readonly isGitRepository: (cwd: string) => Effect.Effect; + + /** + * Capture a checkpoint commit and store it at the provided checkpoint ref. + * + * Uses an isolated temporary Git index and writes a hidden ref. + */ + readonly captureCheckpoint: ( + input: CaptureCheckpointInput, + ) => Effect.Effect; + + /** Check whether a checkpoint ref exists. */ + readonly hasCheckpointRef: ( + input: Omit, + ) => Effect.Effect; + + /** + * Restore workspace and staging state to a checkpoint. + * + * Optionally falls back to current `HEAD` when the checkpoint ref is missing. + */ + readonly restoreCheckpoint: ( + input: RestoreCheckpointInput, + ) => Effect.Effect; + + /** + * Compute a patch diff between two checkpoint refs. + * + * Can optionally treat a missing "from" ref as `HEAD`. + */ + readonly diffCheckpoints: ( + input: DiffCheckpointsInput, + ) => Effect.Effect; + + /** + * Delete the provided checkpoint refs. + * + * Best-effort delete: missing refs are tolerated. + */ + readonly deleteCheckpointRefs: ( + input: DeleteCheckpointRefsInput, + ) => Effect.Effect; + } +>()("t3/checkpointing/CheckpointStore") {} + +export const make = Effect.gen(function* () { + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + + const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* vcsRegistry.resolve({ cwd }); + if (!handle.driver.checkpoints) { + return yield* new VcsUnsupportedOperationError({ + operation, + kind: handle.kind, + detail: `${handle.kind} driver does not implement checkpoint operations.`, + }); + } + return handle.driver.checkpoints satisfies VcsCheckpointOps; + }); + + const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => + vcsRegistry + .detect({ cwd, requestedKind: "git" }) + .pipe(Effect.map((repository) => repository !== null)); + + const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( + "captureCheckpoint", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); + return yield* checkpoints.captureCheckpoint(input); + }); + + const hasCheckpointRef: CheckpointStore["Service"]["hasCheckpointRef"] = Effect.fn( + "hasCheckpointRef", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); + return yield* checkpoints.hasCheckpointRef(input); + }); + + const restoreCheckpoint: CheckpointStore["Service"]["restoreCheckpoint"] = Effect.fn( + "restoreCheckpoint", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); + return yield* checkpoints.restoreCheckpoint(input); + }); + + const diffCheckpoints: CheckpointStore["Service"]["diffCheckpoints"] = Effect.fn( + "diffCheckpoints", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); + return yield* checkpoints.diffCheckpoints(input); + }); + + const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( + "deleteCheckpointRefs", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints( + "CheckpointStore.deleteCheckpointRefs", + input.cwd, + ); + return yield* checkpoints.deleteCheckpointRefs(input); + }); + + return CheckpointStore.of({ + isGitRepository, + captureCheckpoint, + hasCheckpointRef, + restoreCheckpoint, + diffCheckpoints, + deleteCheckpointRefs, + }); +}); + +export const layer = Layer.effect(CheckpointStore, make); diff --git a/apps/server/src/checkpointing/Errors.test.ts b/apps/server/src/checkpointing/Errors.test.ts new file mode 100644 index 00000000000..4c8b9c59cc3 --- /dev/null +++ b/apps/server/src/checkpointing/Errors.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; + +import { + CheckpointRefUnavailableError, + CheckpointTurnRangeUnavailableError, + CheckpointWorkspacePathMissingError, +} from "./Errors.ts"; + +const threadId = ThreadId.make("thread-1"); + +it("derives checkpoint messages from structured context", () => { + const range = new CheckpointTurnRangeUnavailableError({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + requestedTurnCount: 4, + availableTurnCount: 2, + }); + const checkpoint = new CheckpointRefUnavailableError({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + turnCount: 2, + checkpoint: "to", + }); + const workspace = new CheckpointWorkspacePathMissingError({ + operation: "CheckpointDiffQuery.getFullThreadDiff", + threadId, + }); + + expect(range.message).toBe( + "Checkpoint unavailable for thread thread-1 turn 4: Turn diff range exceeds current turn count: requested 4, current 2.", + ); + expect(checkpoint.message).toBe( + "Checkpoint unavailable for thread thread-1 turn 2: Checkpoint ref is unavailable for turn 2.", + ); + expect(workspace.message).toBe( + "Checkpoint invariant violation in CheckpointDiffQuery.getFullThreadDiff: Workspace path missing for thread 'thread-1' when computing full thread diff.", + ); +}); diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index 6feb58d584a..bdf409e2971 100644 --- a/apps/server/src/checkpointing/Errors.ts +++ b/apps/server/src/checkpointing/Errors.ts @@ -1,40 +1,94 @@ +import { NonNegativeInt, ThreadId, type VcsError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; + import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; -import type { VcsError } from "@t3tools/contracts"; -/** - * CheckpointUnavailableError - Expected checkpoint does not exist. - */ -export class CheckpointUnavailableError extends Schema.TaggedErrorClass()( - "CheckpointUnavailableError", +export const CheckpointDiffOperation = Schema.Literals([ + "CheckpointDiffQuery.getTurnDiff", + "CheckpointDiffQuery.getFullThreadDiff", +]); +export type CheckpointDiffOperation = typeof CheckpointDiffOperation.Type; + +/** The computed result does not satisfy the checkpoint RPC contract. */ +export class CheckpointDiffResultInvalidError extends Schema.TaggedErrorClass()( + "CheckpointDiffResultInvalidError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + const result = + this.operation === "CheckpointDiffQuery.getTurnDiff" ? "turn diff" : "full thread diff"; + return `Checkpoint invariant violation in ${this.operation}: Computed ${result} result does not satisfy contract schema.`; + } +} + +/** Projection state no longer contains the requested checkpoint thread. */ +export class CheckpointThreadNotFoundError extends Schema.TaggedErrorClass()( + "CheckpointThreadNotFoundError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + return `Checkpoint invariant violation in ${this.operation}: Thread '${this.threadId}' not found.`; + } +} + +/** The checkpoint thread has no workspace path from which to compute a diff. */ +export class CheckpointWorkspacePathMissingError extends Schema.TaggedErrorClass()( + "CheckpointWorkspacePathMissingError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + const diff = + this.operation === "CheckpointDiffQuery.getTurnDiff" ? "turn diff" : "full thread diff"; + return `Checkpoint invariant violation in ${this.operation}: Workspace path missing for thread '${this.threadId}' when computing ${diff}.`; + } +} + +/** The requested turn lies beyond the latest available checkpoint. */ +export class CheckpointTurnRangeUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointTurnRangeUnavailableError", { - threadId: Schema.String, - turnCount: Schema.Number, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: CheckpointDiffOperation, + threadId: ThreadId, + requestedTurnCount: NonNegativeInt, + availableTurnCount: NonNegativeInt, }, ) { override get message(): string { - return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.requestedTurnCount}: Turn diff range exceeds current turn count: requested ${this.requestedTurnCount}, current ${this.availableTurnCount}.`; } } -/** - * CheckpointInvariantError - Inconsistent provider/filesystem/catalog state. - */ -export class CheckpointInvariantError extends Schema.TaggedErrorClass()( - "CheckpointInvariantError", +/** Expected checkpoint metadata does not contain the requested Git ref. */ +export class CheckpointRefUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointRefUnavailableError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: CheckpointDiffOperation, + threadId: ThreadId, + turnCount: NonNegativeInt, + checkpoint: Schema.Literals(["from", "to"]), }, ) { override get message(): string { - return `Checkpoint invariant violation in ${this.operation}: ${this.detail}`; + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: Checkpoint ref is unavailable for turn ${this.turnCount}.`; } } -export type CheckpointStoreError = VcsError | CheckpointInvariantError | CheckpointUnavailableError; +export type CheckpointStoreError = VcsError; -export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError; +export type CheckpointServiceError = + | CheckpointStoreError + | ProjectionRepositoryError + | CheckpointDiffResultInvalidError + | CheckpointThreadNotFoundError + | CheckpointWorkspacePathMissingError + | CheckpointTurnRangeUnavailableError + | CheckpointRefUnavailableError; diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts deleted file mode 100644 index 9f31532855a..00000000000 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { describe, expect, it } from "vite-plus/test"; - -import { - ProjectionSnapshotQuery, - type ProjectionThreadCheckpointContext, -} from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { checkpointRefForThreadTurn } from "../Utils.ts"; -import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; -import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; -import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; - -function makeThreadCheckpointContext(input: { - readonly projectId: ProjectId; - readonly threadId: ThreadId; - readonly workspaceRoot: string; - readonly worktreePath: string | null; - readonly checkpointTurnCount: number; - readonly checkpointRef: CheckpointRef; -}): ProjectionThreadCheckpointContext { - return { - threadId: input.threadId, - projectId: input.projectId, - workspaceRoot: input.workspaceRoot, - worktreePath: input.worktreePath, - checkpoints: [ - { - turnId: TurnId.make("turn-1"), - checkpointTurnCount: input.checkpointTurnCount, - checkpointRef: input.checkpointRef, - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-01-01T00:00:00.000Z", - }, - ], - }; -} - -describe("CheckpointDiffQueryLive", () => { - it("uses the narrow full-thread context lookup for all-turns diffs", async () => { - const projectId = ProjectId.make("project-full-thread"); - const threadId = ThreadId.make("thread-full-thread"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 4); - let getThreadCheckpointContextCalls = 0; - let getFullThreadDiffContextCalls = 0; - const diffCheckpointsCalls: Array<{ - readonly fromCheckpointRef: CheckpointRef; - readonly toCheckpointRef: CheckpointRef; - readonly cwd: string; - readonly ignoreWhitespace: boolean; - }> = []; - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ - fromCheckpointRef, - toCheckpointRef, - cwd, - ignoreWhitespace, - }); - return "full thread diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => - Effect.sync(() => { - getThreadCheckpointContextCalls += 1; - return Option.none(); - }), - getFullThreadDiffContext: () => - Effect.sync(() => { - getFullThreadDiffContextCalls += 1; - return Option.some({ - threadId, - projectId, - workspaceRoot: "/tmp/workspace", - worktreePath: "/tmp/worktree", - latestCheckpointTurnCount: 4, - toCheckpointRef, - }); - }), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getFullThreadDiff({ - threadId, - toTurnCount: 4, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(getThreadCheckpointContextCalls).toBe(0); - expect(getFullThreadDiffContextCalls).toBe(1); - expect(diffCheckpointsCalls).toEqual([ - { - cwd: "/tmp/worktree", - fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), - toCheckpointRef, - ignoreWhitespace: true, - }, - ]); - expect(result).toEqual({ - threadId, - fromTurnCount: 0, - toTurnCount: 4, - diff: "full thread diff patch", - }); - }); - - it("computes diffs using canonical turn-0 checkpoint refs", async () => { - const projectId = ProjectId.make("project-1"); - const threadId = ThreadId.make("thread-1"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const diffCheckpointsCalls: Array<{ - readonly fromCheckpointRef: CheckpointRef; - readonly toCheckpointRef: CheckpointRef; - readonly cwd: string; - readonly ignoreWhitespace: boolean; - }> = []; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ - fromCheckpointRef, - toCheckpointRef, - cwd, - ignoreWhitespace, - }); - return "diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); - expect(diffCheckpointsCalls).toEqual([ - { - cwd: "/tmp/workspace", - fromCheckpointRef: expectedFromRef, - toCheckpointRef, - ignoreWhitespace: true, - }, - ]); - expect(result).toEqual({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - diff: "diff patch", - }); - }); - - it("defaults to hide whitespace changes", async () => { - const projectId = ProjectId.make("project-default-whitespace"); - const threadId = ThreadId.make("thread-default-whitespace"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = []; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ ignoreWhitespace }); - return "diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); - }); - - it("does not preflight checkpoint refs before diffing", async () => { - const projectId = ProjectId.make("project-no-preflight"); - const threadId = ThreadId.make("thread-no-preflight"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - let hasCheckpointRefCallCount = 0; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => - Effect.sync(() => { - hasCheckpointRefCallCount += 1; - return true; - }), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: () => Effect.succeed("diff patch"), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(hasCheckpointRefCallCount).toBe(0); - }); - - it("fails when the thread is missing from the snapshot", async () => { - const threadId = ThreadId.make("thread-missing"); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: () => Effect.succeed(""), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await expect( - Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - }); - }).pipe(Effect.provide(layer)), - ), - ).rejects.toThrow("Thread 'thread-missing' not found."); - }); -}); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts deleted file mode 100644 index 53b8d163e4c..00000000000 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * CheckpointStoreLive - Filesystem checkpoint store adapter layer. - * - * Resolves the active VCS driver once per checkpoint operation and delegates - * checkpoint-specific behavior to the driver's optional checkpoint capability. - * - * @module CheckpointStoreLive - */ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; -import { VcsUnsupportedOperationError } from "@t3tools/contracts"; -import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; -import type { VcsCheckpointOps } from "../../vcs/VcsDriver.ts"; - -const makeCheckpointStore = Effect.gen(function* () { - const vcsRegistry = yield* VcsDriverRegistry; - - const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( - operation: string, - cwd: string, - ) { - const handle = yield* vcsRegistry.resolve({ cwd }); - if (!handle.driver.checkpoints) { - return yield* new VcsUnsupportedOperationError({ - operation, - kind: handle.kind, - detail: `${handle.kind} driver does not implement checkpoint operations.`, - }); - } - return handle.driver.checkpoints satisfies VcsCheckpointOps; - }); - - const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => - vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe( - Effect.map(() => true), - Effect.orElseSucceed(() => false), - ); - - const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn( - "captureCheckpoint", - )(function* (input) { - const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); - return yield* checkpoints.captureCheckpoint(input); - }); - - const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = Effect.fn("hasCheckpointRef")( - function* (input) { - const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); - return yield* checkpoints.hasCheckpointRef(input); - }, - ); - - const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn( - "restoreCheckpoint", - )(function* (input) { - const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); - return yield* checkpoints.restoreCheckpoint(input); - }); - - const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")( - function* (input) { - const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); - return yield* checkpoints.diffCheckpoints(input); - }, - ); - - const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn( - "deleteCheckpointRefs", - )(function* (input) { - const checkpoints = yield* resolveCheckpoints( - "CheckpointStore.deleteCheckpointRefs", - input.cwd, - ); - return yield* checkpoints.deleteCheckpointRefs(input); - }); - - return { - isGitRepository, - captureCheckpoint, - hasCheckpointRef, - restoreCheckpoint, - diffCheckpoints, - deleteCheckpointRefs, - } satisfies CheckpointStoreShape; -}); - -export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); diff --git a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts deleted file mode 100644 index 4bb8b111827..00000000000 --- a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * CheckpointDiffQuery - Query interface for computed checkpoint diffs. - * - * Provides read-only diff operations across checkpoint snapshots used by - * orchestration APIs. - * - * @module CheckpointDiffQuery - */ -import type { - OrchestrationGetFullThreadDiffInput, - OrchestrationGetFullThreadDiffResult, - OrchestrationGetTurnDiffInput, - OrchestrationGetTurnDiffResult, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { CheckpointServiceError } from "../Errors.ts"; - -/** - * CheckpointDiffQueryShape - Service API for checkpoint diff queries. - */ -export interface CheckpointDiffQueryShape { - /** - * Read the patch diff for a single turn checkpoint transition. - * - * Verifies checkpoint availability in both projection state and filesystem. - */ - readonly getTurnDiff: ( - input: OrchestrationGetTurnDiffInput, - ) => Effect.Effect; - - /** - * Read the full patch diff across a thread range of checkpoints. - * - * Delegates to turn diff with `fromTurnCount = 0`. - */ - readonly getFullThreadDiff: ( - input: OrchestrationGetFullThreadDiffInput, - ) => Effect.Effect; -} - -/** - * CheckpointDiffQuery - Service tag for checkpoint diff queries. - */ -export class CheckpointDiffQuery extends Context.Service< - CheckpointDiffQuery, - CheckpointDiffQueryShape ->()("t3/checkpointing/Services/CheckpointDiffQuery") {} diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts deleted file mode 100644 index a7c4c3dbef0..00000000000 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. - * - * Owns hidden Git-ref checkpoint capture/restore and diff computation for a - * workspace thread timeline. It does not store user-facing checkpoint metadata - * and does not coordinate provider conversation rollback. - * - * Uses Effect `Context.Service` for dependency injection and exposes typed - * domain errors for checkpoint storage operations. - * - * @module CheckpointStore - */ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { CheckpointStoreError } from "../Errors.ts"; -import { CheckpointRef } from "@t3tools/contracts"; - -export interface CaptureCheckpointInput { - readonly cwd: string; - readonly checkpointRef: CheckpointRef; -} - -export interface RestoreCheckpointInput { - readonly cwd: string; - readonly checkpointRef: CheckpointRef; - readonly fallbackToHead?: boolean; -} - -export interface DiffCheckpointsInput { - readonly cwd: string; - readonly fromCheckpointRef: CheckpointRef; - readonly toCheckpointRef: CheckpointRef; - readonly fallbackFromToHead?: boolean; - readonly ignoreWhitespace: boolean; -} - -export interface DeleteCheckpointRefsInput { - readonly cwd: string; - readonly checkpointRefs: ReadonlyArray; -} - -/** - * CheckpointStoreShape - Service API for checkpoint capture/restore and diff access. - */ -export interface CheckpointStoreShape { - /** - * Check whether cwd is inside a Git worktree. - */ - readonly isGitRepository: (cwd: string) => Effect.Effect; - - /** - * Capture a checkpoint commit and store it at the provided checkpoint ref. - * - * Uses an isolated temporary Git index and writes a hidden ref. - */ - readonly captureCheckpoint: ( - input: CaptureCheckpointInput, - ) => Effect.Effect; - - /** - * Check whether a checkpoint ref exists. - */ - readonly hasCheckpointRef: ( - input: Omit, - ) => Effect.Effect; - - /** - * Restore workspace/staging state to a checkpoint. - * - * Optionally falls back to current `HEAD` when the checkpoint ref is missing. - */ - readonly restoreCheckpoint: ( - input: RestoreCheckpointInput, - ) => Effect.Effect; - - /** - * Compute patch diff between two checkpoint refs. - * - * Can optionally treat missing "from" ref as `HEAD`. - */ - readonly diffCheckpoints: ( - input: DiffCheckpointsInput, - ) => Effect.Effect; - - /** - * Delete the provided checkpoint refs. - * - * Best-effort delete: missing refs are tolerated. - */ - readonly deleteCheckpointRefs: ( - input: DeleteCheckpointRefsInput, - ) => Effect.Effect; -} - -/** - * CheckpointStore - Service tag for checkpoint persistence and restore operations. - */ -export class CheckpointStore extends Context.Service()( - "t3/checkpointing/Services/CheckpointStore", -) {} diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 4f1fc48871d..1b349111811 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -18,7 +18,7 @@ import { formatPairingCredentialList, formatSessionList, } from "../cliAuthFormat.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { authLocationFlags, type CliAuthLocationFlags, @@ -28,7 +28,7 @@ import { const runWithEnvironmentAuth = ( flags: CliAuthLocationFlags, - run: (environmentAuth: EnvironmentAuth.EnvironmentAuthShape) => Effect.Effect, + run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect, options?: { readonly quietLogs?: boolean; }, @@ -43,7 +43,7 @@ const runWithEnvironmentAuth = ( }).pipe( Effect.provide( Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe( - Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ), ), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 795094ef97e..8aa8de65fb1 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -15,18 +15,10 @@ import { Argument, Flag } from "effect/unstable/cli"; import { normalizeBasePath } from "@t3tools/shared/basePath"; import { readBootstrapEnvelope } from "../bootstrap.ts"; -import { - DEFAULT_PORT, - deriveServerPaths, - ensureServerDirectories, - resolveStaticDir, - RuntimeMode, - type ServerConfigShape, - type StartupPresentation, -} from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; -export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( +export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, ); @@ -109,7 +101,7 @@ const EnvServerConfig = Config.all({ Config.withDefault(10_000), ), otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), - mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( + mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe( Config.option, Config.map(Option.getOrUndefined), ), @@ -148,7 +140,7 @@ const EnvServerConfig = Config.all({ }); export interface CliServerFlags { - readonly mode: Option.Option; + readonly mode: Option.Option; readonly port: Option.Option; readonly host: Option.Option; readonly basePath: Option.Option; @@ -219,7 +211,7 @@ export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, options?: { - readonly startupPresentation?: StartupPresentation; + readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; }, ) => @@ -250,7 +242,7 @@ export const resolveServerConfig = ( : Option.none(); const bootstrap = Option.getOrUndefined(bootstrapEnvelope); - const mode: RuntimeMode = Option.getOrElse( + const mode: ServerConfig.RuntimeMode = Option.getOrElse( resolveOptionPrecedence( normalizedFlags.mode, Option.fromUndefinedOr(env.mode), @@ -269,9 +261,9 @@ export const resolveServerConfig = ( onSome: (value) => Effect.succeed(value), onNone: () => { if (mode === "desktop") { - return Effect.succeed(DEFAULT_PORT); + return Effect.succeed(ServerConfig.DEFAULT_PORT); } - return findAvailablePort(DEFAULT_PORT); + return findAvailablePort(ServerConfig.DEFAULT_PORT); }, }, ); @@ -291,8 +283,8 @@ export const resolveServerConfig = ( const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); yield* fs.makeDirectory(cwd, { recursive: true }); - const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - yield* ensureServerDirectories(derivedPaths); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl); + yield* ServerConfig.ensureServerDirectories(derivedPaths); const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( derivedPaths.settingsPath, ); @@ -342,7 +334,7 @@ export const resolveServerConfig = ( ), () => 443, ); - const staticDir = devUrl ? undefined : yield* resolveStaticDir(); + const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( normalizedFlags.host, @@ -358,7 +350,7 @@ export const resolveServerConfig = ( ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - const config: ServerConfigShape = { + const config: ServerConfig.ServerConfig["Service"] = { logLevel, traceMinLevel: env.traceMinLevel, traceTimingEnabled: env.traceTimingEnabled, diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 5fce3bc1cd7..70b0329ac90 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -1,9 +1,14 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as References from "effect/References"; -import { acquireRelayClientForLink } from "./connect.ts"; +import { acquireRelayClientForLink, reportCloudDisconnectResults } from "./connect.ts"; const managedExecutable = { status: "available", @@ -100,3 +105,51 @@ it.effect("reuses an available relay client executable without prompting", () => assert.equal(promptCalls, 0); }), ); + +it.effect("keeps disconnect causes in structured logs and out of console warnings", () => { + const warnings: ReadonlyArray[] = []; + const logs: Readonly>[] = []; + const testConsole = { + ...globalThis.console, + warn: (...args: ReadonlyArray) => { + warnings.push(args); + }, + } satisfies Console.Console; + const logger = Logger.make(({ fiber }) => { + logs.push(fiber.getRef(References.CurrentLogAnnotations)); + }); + const liveFailure = "live unlink private diagnostic"; + const relayFailure = "relay revoke private diagnostic"; + + return reportCloudDisconnectResults({ + clearAuthorization: true, + liveResult: { + status: "failed", + cause: Cause.fail(new Error(liveFailure)), + }, + relayResult: Exit.failCause(Cause.die(new Error(relayFailure))), + }).pipe( + Effect.provideService(Console.Console, testConsole), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + Effect.tap(() => + Effect.sync(() => { + assert.lengthOf(warnings, 2); + const warningText = warnings.flat().map(String).join("\n"); + assert.include(warningText, "running server could not stop its tunnel"); + assert.include(warningText, "Could not revoke the relay-side environment record"); + assert.notInclude(warningText, liveFailure); + assert.notInclude(warningText, relayFailure); + assert.deepEqual( + logs.map(({ operation, clearAuthorization }) => ({ operation, clearAuthorization })), + [ + { operation: "live-server-unlink", clearAuthorization: true }, + { operation: "relay-environment-unlink", clearAuthorization: true }, + ], + ); + const loggedCauses = logs.map((log) => String(log.cause)).join("\n"); + assert.include(loggedCauses, liveFailure); + assert.include(loggedCauses, relayFailure); + }), + ), + ); +}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 54f9fd40da9..3ce53391fa6 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -7,6 +7,7 @@ import { import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -31,9 +32,8 @@ import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; import { relayUrlConfig } from "../cloud/publicConfig.ts"; import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; -import { ServerConfig } from "../config.ts"; -import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -145,7 +145,7 @@ const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")( function* ( - relayClient: RelayClient.RelayClientShape, + relayClient: RelayClient.RelayClient["Service"], confirmInstall: (version: string) => Effect.Effect, reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect, ) { @@ -164,7 +164,7 @@ export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_clie ); const withCloudCliSessionToken = ( - environmentAuth: EnvironmentAuth.EnvironmentAuthShape, + environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], run: (token: string) => Effect.Effect, ) => Effect.acquireUseRelease( @@ -180,10 +180,10 @@ const withCloudCliSessionToken = ( type LiveCloudActionResult = | { readonly status: "not-running" } | { readonly status: "succeeded" } - | { readonly status: "failed"; readonly cause: unknown }; + | { readonly status: "failed"; readonly cause: Cause.Cause }; const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); if (Option.isNone(runtimeState)) { return { status: "not-running" } satisfies LiveCloudActionResult; @@ -212,6 +212,21 @@ type RelayUnlinkResult = | { readonly status: "revoked" } | { readonly status: "not-linked" }; +type CloudDisconnectOperation = "live-server-unlink" | "relay-environment-unlink"; + +const logCloudDisconnectFailure = ( + operation: CloudDisconnectOperation, + clearAuthorization: boolean, + cause: Cause.Cause, +) => + Effect.logWarning("T3 Connect disconnect operation failed.").pipe( + Effect.annotateLogs({ + operation, + clearAuthorization, + cause: Cause.pretty(cause), + }), + ); + const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () { const tokens = yield* CliTokenManager.CloudCliTokenManager; const token = yield* tokens.getExisting; @@ -219,7 +234,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f return { status: "not-authenticated" } satisfies RelayUnlinkResult; } - const environment = yield* ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironment; const environmentId = yield* environment.getEnvironmentId; const relayUrl = yield* relayUrlConfig; const httpClient = yield* HttpClient.HttpClient; @@ -237,6 +252,42 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f : ({ status: "not-linked" } satisfies RelayUnlinkResult); }); +export const reportCloudDisconnectResults = Effect.fn("cloud.cli.report_disconnect_results")( + function* (input: { + readonly clearAuthorization: boolean; + readonly liveResult: LiveCloudActionResult; + readonly relayResult: Exit.Exit; + }) { + if (input.liveResult.status === "failed") { + yield* logCloudDisconnectFailure( + "live-server-unlink", + input.clearAuthorization, + input.liveResult.cause, + ); + yield* Console.warn( + "T3 Connect is disabled, but the running server could not stop its tunnel.\nRestart that server to stop the connector.", + ); + } else { + yield* Console.log("T3 Connect is disabled locally."); + } + + if (Exit.isFailure(input.relayResult)) { + yield* logCloudDisconnectFailure( + "relay-environment-unlink", + input.clearAuthorization, + input.relayResult.cause, + ); + yield* Console.warn( + input.clearAuthorization + ? "Could not revoke the relay-side environment record before signing out.\nThe stored CLI authorization was still removed locally." + : "Could not revoke the relay-side environment record yet.\nRun `t3 connect unlink` again when the relay is reachable.", + ); + } else if (input.relayResult.value.status === "revoked") { + yield* Console.log("Revoked the relay-side environment record."); + } + }, +); + const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { readonly clearAuthorization: boolean; }) { @@ -250,23 +301,11 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { yield* tokens.clear; } - if (liveResult.status === "failed") { - yield* Console.warn( - `T3 Connect is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`, - ); - } else { - yield* Console.log("T3 Connect is disabled locally."); - } - - if (Exit.isFailure(relayResult)) { - yield* Console.warn( - options.clearAuthorization - ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.` - : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 connect unlink\` again when the relay is reachable.`, - ); - } else if (relayResult.value.status === "revoked") { - yield* Console.log("Revoked the relay-side environment record."); - } + yield* reportCloudDisconnectResults({ + clearAuthorization: options.clearAuthorization, + liveResult, + relayResult, + }); if (options.clearAuthorization) { yield* Console.log("Signed out of T3 Connect locally."); @@ -285,8 +324,8 @@ const runCloudCommand = ( | FileSystem.FileSystem | HttpClient.HttpClient | Prompt.Environment - | ServerConfig - | ServerEnvironment + | ServerConfig.ServerConfig + | ServerEnvironment.ServerEnvironment >, options?: { readonly quietLogs?: boolean; @@ -301,11 +340,11 @@ const runCloudCommand = ( CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), RelayClient.layerCloudflared({ baseDir: config.baseDir }), EnvironmentAuth.runtimeLayer, - ServerEnvironmentLive, + ServerEnvironment.layer, headlessRelayClientTracingLayer, ).pipe( Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(Layer.succeed(ServerConfig, config)), + Layer.provideMerge(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ); return yield* run.pipe(Effect.provide(runtimeLayer)); @@ -385,9 +424,9 @@ const connectStatusCommand = Command.make("status", { const status: CloudCliStatus = { desired, authenticated, - linked: cloudUserId !== null, - cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, - relayUrl: relayUrl ? bytesToString(relayUrl) : null, + linked: Option.isSome(cloudUserId), + cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, + relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, relayClient: executable, }; yield* Console.log(formatCloudStatus(status, { json: flags.json })); diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts new file mode 100644 index 00000000000..5395592c889 --- /dev/null +++ b/apps/server/src/cli/project.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; + +import { EnvironmentInternalError } from "@t3tools/contracts"; + +import { + ProjectLiveServerDeclaredResponseError, + ProjectLiveServerRequestError, + projectCommandErrorFromLiveServerRequest, +} from "./project.ts"; + +it("maps declared server failures into structural project command errors", () => { + const cause = new EnvironmentInternalError({ + code: "internal_error", + reason: "orchestration_snapshot_failed", + traceId: "trace-123", + }); + + const error = projectCommandErrorFromLiveServerRequest(cause); + + assert.instanceOf(error, ProjectLiveServerDeclaredResponseError); + assert.strictEqual(error.operation, "callLiveServer"); + assert.strictEqual(error.code, "internal_error"); + assert.strictEqual(error.traceId, "trace-123"); + assert.strictEqual(error.message, "Server request failed (internal_error, trace trace-123)."); + assert.strictEqual(error.cause, cause); +}); + +it("preserves unexpected server failures without deriving the message from them", () => { + const cause = new Error("credential abc123 was rejected"); + + const error = projectCommandErrorFromLiveServerRequest(cause); + + assert.instanceOf(error, ProjectLiveServerRequestError); + assert.strictEqual(error.operation, "callLiveServer"); + assert.strictEqual(error.message, "Failed to call the running server."); + assert.strictEqual(error.cause, cause); +}); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 0d8e7eca15d..710d39c4c29 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -9,11 +9,9 @@ import { } from "@t3tools/contracts"; import * as Console from "effect/Console"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -26,19 +24,18 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; -import { ServerConfig, type ServerConfigShape } from "../config.ts"; -import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerConfig from "../config.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; -import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts"; -import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; import { clearPersistedServerRuntimeState, readPersistedServerRuntimeState, } from "../serverRuntimeState.ts"; -import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; type ProjectMutationTarget = { @@ -53,32 +50,165 @@ type ProjectCliDispatchCommand = Extract< { type: "project.create" | "project.meta.update" | "project.delete" } >; -class ProjectCommandError extends Data.TaggedError("ProjectCommandError")<{ - readonly message: string; -}> {} +const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); + +export class ProjectCommandIdGenerationError extends Schema.TaggedErrorClass()( + "ProjectCommandIdGenerationError", + { + operation: Schema.Literal("generateProjectCommandId"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to generate a project command identifier."; + } +} + +export class ProjectLiveServerDeclaredResponseError extends Schema.TaggedErrorClass()( + "ProjectLiveServerDeclaredResponseError", + { + operation: Schema.Literal("callLiveServer"), + code: Schema.String, + traceId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server request failed (${this.code}, trace ${this.traceId}).`; + } +} + +export class ProjectLiveServerUndeclaredStatusError extends Schema.TaggedErrorClass()( + "ProjectLiveServerUndeclaredStatusError", + { + operation: Schema.Literal("callLiveServer"), + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server request failed with undeclared status ${this.status}.`; + } +} + +export class ProjectLiveServerRequestError extends Schema.TaggedErrorClass()( + "ProjectLiveServerRequestError", + { + operation: Schema.Literal("callLiveServer"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to call the running server."; + } +} + +export class ProjectTitleEmptyError extends Schema.TaggedErrorClass()( + "ProjectTitleEmptyError", + { + operation: Schema.Literal("validateProjectTitle"), + title: Schema.String, + }, +) { + override get message(): string { + return "Project title cannot be empty."; + } +} + +export class ProjectIdentifierEmptyError extends Schema.TaggedErrorClass()( + "ProjectIdentifierEmptyError", + { + operation: Schema.Literal("resolveProjectTarget"), + identifier: Schema.String, + }, +) { + override get message(): string { + return "Project identifier cannot be empty."; + } +} + +export class ProjectNotFoundError extends Schema.TaggedErrorClass()( + "ProjectNotFoundError", + { + operation: Schema.Literal("resolveProjectTarget"), + identifier: Schema.String, + normalizedWorkspaceRoot: Schema.optional(Schema.String), + activeProjectCount: Schema.Number, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `No active project found for '${this.identifier}'.`; + } +} + +export class ProjectAlreadyExistsError extends Schema.TaggedErrorClass()( + "ProjectAlreadyExistsError", + { + operation: Schema.Literal("addProject"), + projectId: ProjectId, + workspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `An active project already exists for '${this.workspaceRoot}'.`; + } +} + +export const ProjectCommandError = Schema.Union([ + ProjectCommandIdGenerationError, + ProjectLiveServerDeclaredResponseError, + ProjectLiveServerUndeclaredStatusError, + ProjectLiveServerRequestError, + ProjectTitleEmptyError, + ProjectIdentifierEmptyError, + ProjectNotFoundError, + ProjectAlreadyExistsError, +]); +export type ProjectCommandError = typeof ProjectCommandError.Type; + +export function projectCommandErrorFromLiveServerRequest(cause: unknown): ProjectCommandError { + if (isEnvironmentHttpCommonError(cause)) { + return new ProjectLiveServerDeclaredResponseError({ + operation: "callLiveServer", + code: cause.code, + traceId: cause.traceId, + cause, + }); + } + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + return new ProjectLiveServerUndeclaredStatusError({ + operation: "callLiveServer", + status: cause.response.status, + cause, + }); + } + + return new ProjectLiveServerRequestError({ operation: "callLiveServer", cause }); +} const projectCommandUuid = Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), Effect.mapError( - () => - new ProjectCommandError({ - message: "Failed to generate a project command identifier.", + (cause) => + new ProjectCommandIdGenerationError({ + operation: "generateProjectCommandId", + cause, }), ), ); const ProjectCliRuntimeLive = Layer.mergeAll( - WorkspacePathsLive, + WorkspacePaths.layer, OrchestrationLayerLive.pipe( - Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceLayerLive), ), ); const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); -const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); const withProjectCliSessionToken = ( - environmentAuth: EnvironmentAuth.EnvironmentAuthShape, + environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], run: (token: string) => Effect.Effect, ) => Effect.acquireUseRelease( @@ -93,28 +223,6 @@ const withProjectCliSessionToken = ( const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); -const failLiveServerRequest = (cause: unknown) => { - if (isEnvironmentHttpCommonError(cause)) { - return Effect.fail( - new ProjectCommandError({ - message: `Server request failed (${cause.code}, trace ${cause.traceId}).`, - }), - ); - } - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - return Effect.fail( - new ProjectCommandError({ - message: `Server request failed with undeclared status ${cause.response.status}.`, - }), - ); - } - return Effect.fail( - new ProjectCommandError({ - message: `Failed to call running server: ${String(cause)}.`, - }), - ); -}; - const makeLiveServerClient = (origin: string) => HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin, @@ -123,7 +231,7 @@ const makeLiveServerClient = (origin: string) => const normalizeWorkspaceRootForProjectCommand = Effect.fn( "normalizeWorkspaceRootForProjectCommand", )(function* (workspaceRoot: string) { - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); }); @@ -136,7 +244,10 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( if (trimmed.length > 0) { return trimmed; } - return yield* new ProjectCommandError({ message: "Project title cannot be empty." }); + return yield* new ProjectTitleEmptyError({ + operation: "validateProjectTitle", + title: explicitTitle, + }); } const path = yield* Path.Path; @@ -150,7 +261,10 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( }) { const trimmedIdentifier = input.identifier.trim(); if (trimmedIdentifier.length === 0) { - return yield* new ProjectCommandError({ message: "Project identifier cannot be empty." }); + return yield* new ProjectIdentifierEmptyError({ + operation: "resolveProjectTarget", + identifier: input.identifier, + }); } const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); @@ -163,12 +277,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( } satisfies ProjectMutationTarget; } - const normalizedWorkspaceRootResult = yield* Effect.exit( + const normalizedWorkspaceRootResult = yield* Effect.result( normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), ); - const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) - ? normalizedWorkspaceRootResult.value - : null; + const normalizedWorkspaceRoot = + normalizedWorkspaceRootResult._tag === "Success" ? normalizedWorkspaceRootResult.success : null; const exactWorkspaceMatch = normalizedWorkspaceRoot === null @@ -177,8 +290,14 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( const resolved = exactWorkspaceMatch; if (!resolved) { - return yield* new ProjectCommandError({ - message: `No active project found for '${trimmedIdentifier}'.`, + return yield* new ProjectNotFoundError({ + operation: "resolveProjectTarget", + identifier: trimmedIdentifier, + activeProjectCount: activeProjects.length, + ...(normalizedWorkspaceRoot === null ? {} : { normalizedWorkspaceRoot }), + ...(normalizedWorkspaceRootResult._tag === "Failure" + ? { cause: normalizedWorkspaceRootResult.failure } + : {}), }); } @@ -195,7 +314,10 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => return yield* client.orchestration.snapshot({ headers: { authorization: `Bearer ${bearerToken}` }, }); - }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); + }).pipe( + withProjectCliLiveServerTimeout, + Effect.mapError(projectCommandErrorFromLiveServerRequest), + ); const dispatchLiveOrchestrationCommand = ( origin: string, @@ -208,15 +330,21 @@ const dispatchLiveOrchestrationCommand = ( headers: { authorization: `Bearer ${bearerToken}` }, payload: command, } as Parameters[0]); - }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); + }).pipe( + withProjectCliLiveServerTimeout, + Effect.mapError(projectCommandErrorFromLiveServerRequest), + ); const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; return yield* projectionSnapshotQuery.getSnapshot(); }); const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( - function* (environmentAuth: EnvironmentAuth.EnvironmentAuthShape, config: ServerConfigShape) { + function* ( + environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], + config: ServerConfig.ServerConfig["Service"], + ) { const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); if (Option.isNone(runtimeState)) { return Option.none<{ readonly origin: string }>(); @@ -230,11 +358,15 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu ), ); - const attempted = yield* Effect.exit(attempt); - if (Exit.isSuccess(attempted)) { - return Option.some(attempted.value); + const attempted = yield* Effect.result(attempt); + if (attempted._tag === "Success") { + return Option.some(attempted.success); } + yield* Effect.logDebug("Failed to connect to the persisted project CLI server.", { + origin: runtimeState.value.origin, + cause: attempted.failure, + }); yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); return Option.none<{ readonly origin: string }>(); }, @@ -251,7 +383,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( }) => Effect.Effect< string, Error, - Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | WorkspacePaths.WorkspacePaths >, ) { const logLevel = yield* GlobalFlag.LogLevel; @@ -278,13 +414,13 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( } const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( - Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ); return yield* Effect.gen(function* () { const snapshot = yield* getOfflineSnapshot(); - const orchestrationEngine = yield* OrchestrationEngineService; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const output = yield* run({ snapshot, dispatch: (command) => orchestrationEngine.dispatch(command), @@ -294,9 +430,9 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( }).pipe(Effect.provide(offlineRuntimeLayer)); }).pipe( Effect.provide( - Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).pipe( + Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePaths.layer).pipe( Layer.provideMerge(FetchHttpClient.layer), - Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ), ), @@ -328,8 +464,10 @@ const projectAddCommand = Command.make("add", { (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, ); if (existingProject) { - return yield* new ProjectCommandError({ - message: `An active project already exists for '${workspaceRoot}'.`, + return yield* new ProjectAlreadyExistsError({ + operation: "addProject", + projectId: existingProject.id, + workspaceRoot, }); } @@ -341,7 +479,7 @@ const projectAddCommand = Command.make("add", { projectId, title, workspaceRoot, - defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), createdAt: DateTime.formatIso(yield* DateTime.now), }); return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; diff --git a/apps/server/src/cloud/CliState.test.ts b/apps/server/src/cloud/CliState.test.ts index 2798f5b6ede..3fbf4f12db2 100644 --- a/apps/server/src/cloud/CliState.test.ts +++ b/apps/server/src/cloud/CliState.test.ts @@ -1,7 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerConfig } from "../config.ts"; @@ -40,18 +41,18 @@ it.layer(NodeServices.layer)("CliState", (it) => { Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; - expect(yield* CliState.readCliDesiredCloudLink).toBe(false); + assert.isFalse(yield* CliState.readCliDesiredCloudLink); yield* CliState.setCliDesiredCloudLink(true); - expect(yield* CliState.readCliDesiredCloudLink).toBe(true); + assert.isTrue(yield* CliState.readCliDesiredCloudLink); for (const name of persistedCloudLinkSecrets) { yield* secrets.set(name, new TextEncoder().encode(name)); } yield* CliState.clearPersistedCloudLink; - expect(yield* CliState.readCliDesiredCloudLink).toBe(false); + assert.isFalse(yield* CliState.readCliDesiredCloudLink); for (const name of persistedCloudLinkSecrets) { - expect(yield* secrets.get(name)).toBe(null); + assert.isTrue(Option.isNone(yield* secrets.get(name))); } }).pipe(Effect.provide(makeTestLayer())), ); diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts index f344a0b73cc..2e18fff4250 100644 --- a/apps/server/src/cloud/CliState.ts +++ b/apps/server/src/cloud/CliState.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { @@ -17,7 +18,7 @@ const TRUE_BYTES = new TextEncoder().encode("true"); export const readCliDesiredCloudLink = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; - return (yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)) !== null; + return Option.isSome(yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)); }); export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* ( diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 765ef058332..00709370b26 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -1,12 +1,11 @@ // @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary. -import { createServer } from "node:http"; +import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -15,10 +14,12 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; @@ -45,35 +46,74 @@ const OAuthTokenResponse = Schema.Struct({ token_type: Schema.String, }); -export class CloudCliTokenManagerError extends Data.TaggedError("CloudCliTokenManagerError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass()( + "CloudCliCredentialRemovalError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not remove the stored T3 Connect CLI credential."; + } +} + +export class CloudCliCredentialRefreshError extends Schema.TaggedErrorClass()( + "CloudCliCredentialRefreshError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not refresh the T3 Connect CLI credential."; + } +} + +export class CloudCliCredentialReadError extends Schema.TaggedErrorClass()( + "CloudCliCredentialReadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not read the stored T3 Connect CLI credential."; + } +} + +export class CloudCliAuthorizationError extends Schema.TaggedErrorClass()( + "CloudCliAuthorizationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not authorize the T3 Connect CLI."; + } +} -export interface CloudCliTokenManagerShape { - readonly get: Effect.Effect; - readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; - readonly hasCredential: Effect.Effect; - readonly clear: Effect.Effect; +export class CloudCliAuthorizationTimeoutError extends Schema.TaggedErrorClass()( + "CloudCliAuthorizationTimeoutError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Timed out waiting for T3 Connect authorization."; + } } +export const CloudCliTokenManagerError = Schema.Union([ + CloudCliCredentialRemovalError, + CloudCliCredentialRefreshError, + CloudCliCredentialReadError, + CloudCliAuthorizationError, + CloudCliAuthorizationTimeoutError, +]); +export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; + export class CloudCliTokenManager extends Context.Service< CloudCliTokenManager, - CloudCliTokenManagerShape + { + readonly get: Effect.Effect; + readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; + readonly hasCredential: Effect.Effect; + readonly clear: Effect.Effect; + } >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} const wrapError = - (message: string) => - (effect: Effect.Effect): Effect.Effect => - effect.pipe( - Effect.mapError( - (cause) => - new CloudCliTokenManagerError({ - message, - cause, - }), - ), - ); + (makeError: (cause: unknown) => WrappedError) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.mapError(makeError)); function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); @@ -83,7 +123,7 @@ function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); const secrets = yield* ServerSecretStore.ServerSecretStore; @@ -96,12 +136,12 @@ const make = Effect.gen(function* () { const clear = secrets .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) - .pipe(wrapError("Could not remove the stored T3 Connect CLI credential.")); + .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause }))); const read = Effect.fn("cloud.cli_token.read")(function* () { const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); - if (!encoded) return Option.none(); - return Option.some(yield* decodePersistedToken(bytesToString(encoded))); + if (Option.isNone(encoded)) return Option.none(); + return Option.some(yield* decodePersistedToken(bytesToString(encoded.value))); }); const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( @@ -166,7 +206,7 @@ const make = Effect.gen(function* () { disableLogger: true, }).pipe( Layer.provide( - NodeHttpServer.layer(createServer, { + NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", port: 34338, disablePreemptiveShutdown: true, @@ -185,10 +225,10 @@ const make = Effect.gen(function* () { yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); const code = yield* Deferred.await(callback).pipe( Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), - Effect.catchTag("TimeoutError", () => + Effect.catchTag("TimeoutError", (cause) => Effect.fail( - new CloudCliTokenManagerError({ - message: "Timed out waiting for T3 Connect authorization.", + new CloudCliAuthorizationTimeoutError({ + cause, }), ), ), @@ -213,12 +253,12 @@ const make = Effect.gen(function* () { }); const getExisting = semaphore.withPermits(1)( - getExistingNoLock().pipe(wrapError("Could not refresh the T3 Connect CLI credential.")), + getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), ); const hasCredential = semaphore.withPermits(1)( read().pipe( Effect.map(Option.isSome), - wrapError("Could not read the stored T3 Connect CLI credential."), + wrapError((cause) => new CloudCliCredentialReadError({ cause })), ), ); const get = semaphore.withPermits(1)( @@ -227,7 +267,7 @@ const make = Effect.gen(function* () { return Option.isSome(token) ? token.value : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); - }).pipe(wrapError("Could not authorize the T3 Connect CLI.")), + }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), ); return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 9ce33deaaef..e0d5924fcc2 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -4,13 +4,15 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; const relayClientAvailableLayer = Layer.succeed( RelayClient.RelayClient, @@ -26,12 +28,33 @@ const relayClientAvailableLayer = Layer.succeed( }), ); -const runtimeDependencies = (spawner: ReturnType) => +const runtimeDependencies = ( + spawner: ReturnType, + relayClientLayer = relayClientAvailableLayer, +) => Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - relayClientAvailableLayer, + relayClientLayer, + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: () => Effect.succeed(Option.none()), + }), ); +const buildCloudManagedEndpointRuntime = ( + spawner: ReturnType, + relayClientLayer = relayClientAvailableLayer, +) => + Effect.gen(function* () { + const context = yield* Layer.build( + ManagedEndpointRuntime.layer.pipe( + Layer.provide(runtimeDependencies(spawner, relayClientLayer)), + ), + ); + return yield* Effect.service(ManagedEndpointRuntime.CloudManagedEndpointRuntime).pipe( + Effect.provide(context), + ); + }); + function makeHandle(input: { readonly pid: number; readonly onKill: () => void; @@ -57,6 +80,24 @@ function makeHandle(input: { } describe("CloudManagedEndpointRuntime", () => { + it("classifies Cloudflare connection and warning output", () => { + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0", + ), + ).toBe("connected"); + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z ERR Failed to serve tunnel connection", + ), + ).toBe("warning"); + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z INF Starting metrics server", + ), + ).toBe("debug"); + }); + it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => Effect.gen(function* () { const spawned: Array = []; @@ -80,9 +121,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", @@ -113,8 +152,8 @@ describe("CloudManagedEndpointRuntime", () => { "token-1", "token-2", ]); - expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]); - expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]); + expect(spawned.map((command) => command.options.stdout)).toEqual(["pipe", "pipe"]); + expect(spawned.map((command) => command.options.stderr)).toEqual(["pipe", "pipe"]); expect(spawned.map((command) => command.options.detached)).toEqual([false, false]); expect(spawned.map((command) => command.options.shell)).toEqual([false, false]); expect(killed).toEqual([100, 101]); @@ -137,9 +176,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const started = yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", @@ -176,9 +213,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const config = { providerKind: "cloudflare_tunnel" as const, connectorToken: "token", @@ -223,9 +258,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const started = yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", @@ -265,9 +298,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const first = yield* runtime .applyConfig({ @@ -305,9 +336,7 @@ describe("CloudManagedEndpointRuntime", () => { }), ), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const status = yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", @@ -327,22 +356,18 @@ describe("CloudManagedEndpointRuntime", () => { Effect.gen(function* () { const spawn = vi.fn(); const spawner = ChildProcessSpawner.make(spawn); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.succeed({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, - }), - install: Effect.die("unused"), - installWithProgress: () => Effect.die("unused"), - }), - ), - ), + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused"), + installWithProgress: () => Effect.die("unused"), + }), ), ); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 73e549ebf49..a1d7112a929 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -9,7 +9,9 @@ import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; @@ -21,23 +23,12 @@ function bytesToString(bytes: Uint8Array): string { const readRuntimeConfig = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const bytes = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); - if (!bytes) { + if (Option.isNone(bytes)) { return null; } - return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes))); + return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes.value))); }); -export interface CloudManagedEndpointRuntimeShape { - readonly applyConfig: ( - config: RelayManagedEndpointRuntimeConfig | null, - ) => Effect.Effect; -} - -export class CloudManagedEndpointRuntime extends Context.Service< - CloudManagedEndpointRuntime, - CloudManagedEndpointRuntimeShape ->()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} - export type CloudManagedEndpointRuntimeStatus = | { readonly status: "disabled"; @@ -61,6 +52,15 @@ export type CloudManagedEndpointRuntimeStatus = readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; }; +export class CloudManagedEndpointRuntime extends Context.Service< + CloudManagedEndpointRuntime, + { + readonly applyConfig: ( + config: RelayManagedEndpointRuntimeConfig | null, + ) => Effect.Effect; + } +>()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} + interface ActiveConnector { readonly child: ChildProcessSpawner.ChildProcessHandle; readonly scope: Scope.Closeable; @@ -68,6 +68,13 @@ interface ActiveConnector { readonly config: RelayManagedEndpointRuntimeConfig; } +export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { + if (/\bRegistered tunnel connection\b/iu.test(line)) { + return "connected"; + } + return /\b(?:ERR|WRN)\b/u.test(line) ? "warning" : "debug"; +} + function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { return JSON.stringify({ providerKind: config.providerKind, @@ -89,13 +96,13 @@ const stopConnector = (connector: ActiveConnector | null) => ) : Effect.void; -export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const relayClient = yield* RelayClient.RelayClient; const activeRef = yield* Ref.make(null); const desiredConfigRef = yield* Ref.make(null); const reconcileSemaphore = yield* Semaphore.make(1); - let reconcileConfig: CloudManagedEndpointRuntimeShape["applyConfig"]; + let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; const stopActive = Effect.gen(function* () { const active = yield* Ref.getAndSet(activeRef, null); @@ -141,6 +148,39 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); + const observeConnectorOutput = (connector: ActiveConnector) => + connector.child.all.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.map((line) => line.trim()), + Stream.filter((line) => line.length > 0), + Stream.runForEach((line) => { + const output = line.replaceAll(connector.config.connectorToken, ""); + const attributes = { + pid: Number(connector.child.pid), + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + output, + }; + switch (classifyRelayClientOutput(line)) { + case "connected": + return Effect.logInfo("Relay client tunnel connection registered", attributes); + case "warning": + return Effect.logWarning("Relay client reported a transport warning", attributes); + case "debug": + return Effect.logDebug("Relay client output", attributes); + } + }), + Effect.catchCause((cause) => + Effect.logWarning("Relay client output observer failed", { + cause, + pid: Number(connector.child.pid), + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + }), + ), + ); + reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { if (!config || config.providerKind !== "cloudflare_tunnel") { yield* stopActive; @@ -190,14 +230,15 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { TUNNEL_TOKEN: config.connectorToken, }, shell: false, - stderr: "ignore", - stdout: "ignore", + stderr: "pipe", + stdout: "pipe", }), ) .pipe( Effect.provideService(Scope.Scope, connectorScope), - Effect.tap(() => - Effect.logInfo("Relay client started", { + Effect.tap((child) => + Effect.logInfo("Relay client process started; waiting for tunnel connection", { + pid: Number(child.pid), tunnelId: config.tunnelId, tunnelName: config.tunnelName, }), @@ -232,6 +273,7 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { config, } satisfies ActiveConnector; yield* Ref.set(activeRef, connector); + yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); yield* Effect.forkIn(superviseConnector(connector), connectorScope); return { status: "running", @@ -258,24 +300,20 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { ), ); - return CloudManagedEndpointRuntime.of({ + const runtime = CloudManagedEndpointRuntime.of({ applyConfig, }); -}); -export const layer = Layer.effect( - CloudManagedEndpointRuntime, - Effect.gen(function* () { - const runtime = yield* makeCloudManagedEndpointRuntime; - const initialConfig = yield* readRuntimeConfig.pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( - Effect.as(null), - ), + const initialConfig = yield* readRuntimeConfig.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( + Effect.as(null), ), - ); - yield* runtime.applyConfig(initialConfig); - yield* Effect.addFinalizer(() => runtime.applyConfig(null)); - return runtime; - }), -); + ), + ); + yield* runtime.applyConfig(initialConfig); + yield* Effect.addFinalizer(() => runtime.applyConfig(null)); + return runtime; +}); + +export const layer = Layer.effect(CloudManagedEndpointRuntime, make); diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 3a033d50303..48c44ccc48a 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -1,11 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; const makeServerSecretStoreLayer = () => @@ -23,10 +24,10 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it const first = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore); const second = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore); - expect(second).toEqual(first); - expect(yield* secretStore.get("cloud-link-ed25519-key-pair")).not.toBeNull(); - expect(yield* secretStore.get("cloud-link-ed25519-private-key")).toBeNull(); - expect(yield* secretStore.get("cloud-link-ed25519-public-key")).toBeNull(); + assert.deepEqual(second, first); + assert.isTrue(Option.isSome(yield* secretStore.get("cloud-link-ed25519-key-pair"))); + assert.isTrue(Option.isNone(yield* secretStore.get("cloud-link-ed25519-private-key"))); + assert.isTrue(Option.isNone(yield* secretStore.get("cloud-link-ed25519-public-key"))); }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); @@ -36,11 +37,11 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it yield* secretStore.set("cloud-link-ed25519-private-key", new TextEncoder().encode("private")); yield* secretStore.set("cloud-link-ed25519-public-key", new TextEncoder().encode("public")); - expect(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore)).toEqual({ + assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { privateKey: "private", publicKey: "public", }); - expect(yield* secretStore.get("cloud-link-ed25519-key-pair")).not.toBeNull(); + assert.isTrue(Option.isSome(yield* secretStore.get("cloud-link-ed25519-key-pair"))); }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); @@ -53,7 +54,9 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it const secretStore = { get: (name) => Effect.sync(() => - name === "cloud-link-ed25519-key-pair" && createAttempted ? winner : null, + name === "cloud-link-ed25519-key-pair" && createAttempted + ? Option.some(winner) + : Option.none(), ), set: unusedSecretStoreOperation, create: () => @@ -62,8 +65,8 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }).pipe( Effect.flatMap(() => Effect.fail( - new ServerSecretStore.SecretStoreError({ - message: "Concurrent keypair creation won.", + new ServerSecretStore.SecretStorePersistError({ + resource: "environment signing key pair", cause: PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", @@ -76,9 +79,9 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it ), getOrCreateRandom: unusedSecretStoreOperation, remove: unusedSecretStoreOperation, - } satisfies ServerSecretStore.ServerSecretStoreShape; + } satisfies ServerSecretStore.ServerSecretStore["Service"]; - expect(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore)).toEqual({ + assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { privateKey: "winner-private", publicKey: "winner-public", }); diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index beef4729992..1d0cde91bf4 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -1,5 +1,6 @@ import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -26,45 +27,47 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -const keyPairPersistenceError = (message: string, cause?: unknown) => - new ServerSecretStore.SecretStoreError({ message, cause }); +const KEY_PAIR_RESOURCE = "environment signing key pair"; + +const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError => + new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause }); + +const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError => + new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause }); + +const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError => + new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE }); const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ) { const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR); - if (encoded === null) { - return null; + if (Option.isNone(encoded)) { + return Option.none(); } - return yield* decodeEnvironmentKeyPair(bytesToString(encoded)).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to decode environment signing key pair.", cause), - ), + const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( + Effect.mapError(keyPairDecodeError), ); + return Option.some(decoded); }); const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], keyPair: EnvironmentKeyPair, ) { const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to encode environment signing key pair.", cause), - ), + Effect.mapError(keyPairEncodeError), ); return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( Effect.as(keyPair), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? readEnvironmentKeyPair(secrets).pipe( - Effect.flatMap((existing) => - existing !== null - ? Effect.succeed(existing) - : Effect.fail( - keyPairPersistenceError( - "Failed to read environment signing key pair after concurrent creation.", - ), - ), + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => Effect.fail(keyPairConcurrentReadError()), + }), ), ) : Effect.fail(error), @@ -73,19 +76,19 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio }); export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ) { const existing = yield* readEnvironmentKeyPair(secrets); - if (existing !== null) { - return existing; + if (Option.isSome(existing)) { + return existing.value; } const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY); const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY); - if (existingPrivate && existingPublic) { + if (Option.isSome(existingPrivate) && Option.isSome(existingPublic)) { return yield* persistEnvironmentKeyPair(secrets, { - privateKey: bytesToString(existingPrivate), - publicKey: bytesToString(existingPublic), + privateKey: bytesToString(existingPrivate.value), + publicKey: bytesToString(existingPublic.value), }); } diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 8ea7ca06f9a..ed2e5a4cf75 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -9,21 +9,15 @@ import { HttpClient, HttpServerRequest } from "effect/unstable/http"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import { - consumeCloudReplayGuards, - reconcileDesiredCloudLink, - traceRelayBrokerHandler, -} from "./http.ts"; -import { - CloudManagedEndpointRuntime, - type CloudManagedEndpointRuntimeShape, -} from "./ManagedEndpointRuntime.ts"; +import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; +import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist cloud replay guard.", + new ServerSecretStore.SecretStorePersistError({ + resource: "cloud replay guard", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -35,8 +29,8 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); function makeSecretStore( - create: ServerSecretStore.ServerSecretStoreShape["create"], -): ServerSecretStore.ServerSecretStoreShape { + create: ServerSecretStore.ServerSecretStore["Service"]["create"], +): ServerSecretStore.ServerSecretStore["Service"] { return { get: unusedSecretStoreOperation, set: unusedSecretStoreOperation, @@ -46,6 +40,30 @@ function makeSecretStore( }; } +it("preserves messages surfaced by cloud 500 responses", () => { + const cause = new Error("cloud operation failed"); + + expect([ + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message, + ]).toEqual([ + "Could not verify the linked cloud account.", + "Could not read the linked cloud account.", + "Cloud linked user is not installed for this environment.", + "Failed to sign cloud link JWT.", + "Cloud mint public key is not installed for this environment.", + "Cloud relay issuer is not installed for this environment.", + "Failed to sign cloud health JWT.", + "Failed to sign cloud mint JWT.", + ]); +}); + describe("consumeCloudReplayGuards", () => { it.effect("reports already-created guards as replay conflicts", () => Effect.gen(function* () { @@ -75,8 +93,38 @@ describe("consumeCloudReplayGuards", () => { ); }); -describe("traceRelayBrokerHandler", () => { - it.effect("continues the incoming relay trace with the product tracer", () => +describe("relay request tracing", () => { + it.effect("does not accept an unauthenticated request trace parent", () => + Effect.gen(function* () { + const spans: Array = []; + const productTracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const request = HttpServerRequest.fromWeb( + new Request("https://environment.example.test/api/t3-cloud/mint-credential", { + headers: { + traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", + }, + }), + ); + + yield* traceRelayRequest(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.provideService(RelayClientTracer, Option.some(productTracer)), + ); + + expect(spans).toHaveLength(1); + const span = spans[0]!; + expect(span.traceId).not.toBe("0123456789abcdef0123456789abcdef"); + expect(Option.isNone(span.parent)).toBe(true); + }), + ); + + it.effect("continues an authenticated relay trace with the product tracer", () => Effect.gen(function* () { const spans: Array = []; const productTracer = Tracer.make({ @@ -94,7 +142,9 @@ describe("traceRelayBrokerHandler", () => { }), ); - yield* traceRelayBrokerHandler(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( + yield* traceAuthenticatedRelayRequest( + Effect.void.pipe(Effect.withSpan("relay.mint.handler")), + ).pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.provideService(RelayClientTracer, Option.some(productTracer)), ); @@ -122,21 +172,21 @@ describe("reconcileDesiredCloudLink", () => { makeSecretStore(unusedSecretStoreOperation), ), Effect.provideService( - ServerEnvironment, - ServerEnvironment.of({ + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: unusedSecretStoreOperation(), getDescriptor: unusedSecretStoreOperation(), }), ), Effect.provideService( - CloudManagedEndpointRuntime, - CloudManagedEndpointRuntime.of({ + ManagedEndpointRuntime.CloudManagedEndpointRuntime, + ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ applyConfig: unusedSecretStoreOperation, - } satisfies CloudManagedEndpointRuntimeShape), + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), ), Effect.provideService( EnvironmentAuth.EnvironmentAuth, - EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuthShape), + EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), ), Effect.provideService( CliTokenManager.CloudCliTokenManager, diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index b78d47a20c1..fc2adca9fbc 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -48,21 +48,15 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; -import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { requireEnvironmentScope } from "../auth/http.ts"; -import { - ServerEnvironment, - type ServerEnvironmentShape, -} from "../environment/Services/ServerEnvironment.ts"; -import { - CloudManagedEndpointRuntime, - type CloudManagedEndpointRuntimeShape, -} from "./ManagedEndpointRuntime.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, @@ -74,9 +68,10 @@ import { RELAY_URL_SECRET, } from "./config.ts"; import { relayUrlConfig } from "./publicConfig.ts"; -import * as CliState from "./CliState.ts"; +import { setCliDesiredCloudLink } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; +import { traceRelayRequest } from "./traceRelayRequest.ts"; const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; @@ -102,6 +97,9 @@ const failEnvironmentCloudInternalError = Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), ); +const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => + failEnvironmentCloudInternalError(error.message)(error); + const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( () => @@ -111,19 +109,6 @@ const requireRelayUrl = relayUrlConfig.pipe( ), ); -export const traceRelayBrokerHandler = ( - effect: Effect.Effect, -): Effect.Effect => - HttpServerRequest.HttpServerRequest.pipe( - Effect.flatMap((request) => - Option.match(HttpTraceContext.fromHeaders(request.headers), { - onNone: () => effect, - onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)), - }), - ), - withRelayClientTracing, - ); - function bytesToString(bytes: Uint8Array): string { return new TextDecoder().decode(bytes); } @@ -133,7 +118,7 @@ function stringToBytes(value: string): Uint8Array { } export function consumeCloudReplayGuards(input: { - readonly secrets: ServerSecretStore.ServerSecretStoreShape; + readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; readonly names: ReadonlyArray; readonly value: Uint8Array; }) { @@ -141,7 +126,7 @@ export function consumeCloudReplayGuards(input: { input.names.map((name) => input.secrets.create(name, input.value).pipe( Effect.as(true), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? Effect.succeed(false) : Effect.fail(error), @@ -220,22 +205,21 @@ function validateRelayConfigPayload( } function validateLinkedCloudUser(input: { - readonly secrets: ServerSecretStore.ServerSecretStoreShape; + readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; readonly cloudUserId: string; }): Effect.Effect { return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Could not verify the linked cloud account.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause, }), ), Effect.flatMap((existing) => { - if (!existing) { + if (Option.isNone(existing)) { return Effect.void; } - const existingCloudUserId = bytesToString(existing); + const existingCloudUserId = bytesToString(existing.value); return existingCloudUserId === input.cloudUserId ? Effect.void : Effect.fail( @@ -249,24 +233,19 @@ function validateLinkedCloudUser(input: { } function readInstalledCloudUserId( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ): Effect.Effect { return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Could not read the linked cloud account.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause, }), ), Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud linked user is not installed for this environment.", - }), - ), + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), ), ); } @@ -347,19 +326,19 @@ const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentH const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload); interface CloudHttpDependencies { - readonly secrets: ServerSecretStore.ServerSecretStoreShape; - readonly environment: ServerEnvironmentShape; - readonly endpointRuntime: CloudManagedEndpointRuntimeShape; - readonly environmentAuth: EnvironmentAuth.EnvironmentAuthShape; - readonly cliTokenManager: CliTokenManager.CloudCliTokenManagerShape; + readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; + readonly environment: ServerEnvironment.ServerEnvironment["Service"]; + readonly endpointRuntime: ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]; + readonly environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]; + readonly cliTokenManager: CliTokenManager.CloudCliTokenManager["Service"]; readonly httpClient: HttpClient.HttpClient; } const cloudHttpDependencies = Effect.gen(function* () { return { secrets: yield* ServerSecretStore.ServerSecretStore, - environment: yield* ServerEnvironment, - endpointRuntime: yield* CloudManagedEndpointRuntime, + environment: yield* ServerEnvironment.ServerEnvironment, + endpointRuntime: yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime, environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, httpClient: yield* HttpClient.HttpClient, @@ -409,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud link JWT.", + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause, }), ), @@ -431,15 +409,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not generate environment link proof."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not generate environment link proof."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - }), ); const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( @@ -492,17 +472,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( yield* requireEnvironmentScope(AuthRelayWriteScope); return yield* applyCloudRelayConfig(dependencies, payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), + ), + Effect.catchTag( + "SchemaError", + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), ), - Effect.catchTags({ - SchemaError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - }), ); const relayClientRequest = ( @@ -596,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }, schema: RelayEnvironmentLinkResponse, }); - yield* CliState.setCliDesiredCloudLink(true); + yield* setCliDesiredCloudLink(true); return yield* applyCloudRelayConfig(dependencies, { relayUrl, relayIssuer: link.relayIssuer, @@ -606,12 +586,16 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi endpointRuntime: link.endpointRuntime, }); }, + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), + ), Effect.catchTags({ - CloudCliTokenManagerError: (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), + CloudCliCredentialRemovalError: failCloudCliTokenManagerError, + CloudCliCredentialRefreshError: failCloudCliTokenManagerError, + CloudCliCredentialReadError: failCloudCliTokenManagerError, + CloudCliAuthorizationError: failCloudCliTokenManagerError, + CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, }), ); @@ -634,12 +618,12 @@ const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function { concurrency: 4 }, ); return { - linked: cloudUserId !== null, - cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, - relayUrl: relayUrl ? bytesToString(relayUrl) : null, - relayIssuer: relayIssuer ? bytesToString(relayIssuer) : null, - publishAgentActivity: publishAgentActivity - ? bytesToString(publishAgentActivity) === "true" + linked: Option.isSome(cloudUserId), + cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, + relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, + relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, + publishAgentActivity: Option.isSome(publishAgentActivity) + ? bytesToString(publishAgentActivity.value) === "true" : false, } satisfies EnvironmentCloudLinkStateResult; }); @@ -649,8 +633,8 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( yield* requireEnvironmentScope(AuthRelayReadScope); return yield* readCloudLinkState(dependencies); }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not read environment relay configuration."), ), ); @@ -671,11 +655,11 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( ], { concurrency: 7 }, ); - yield* CliState.setCliDesiredCloudLink(false); + yield* setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not remove environment relay configuration."), ), ); @@ -692,42 +676,40 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( ); return yield* readCloudLinkState(dependencies); }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), ), ); const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { - const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( - Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", - }), - ), - ), - ); - const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( - Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : dependencies.secrets.get(RELAY_URL_SECRET).pipe( - Effect.flatMap((fallbackBytes) => - fallbackBytes - ? Effect.succeed(bytesToString(fallbackBytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", - }), - ), - ), - ), - ), - ); + const cloudMintPublicKey = yield* dependencies.secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* dependencies.secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : dependencies.secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), + ), + ), + ), + ); const environmentId = yield* dependencies.environment.getEnvironmentId; const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); const now = yield* DateTime.now; @@ -789,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud health JWT.", + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause, }), ), @@ -806,45 +787,47 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not answer cloud health request."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not answer cloud health request."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - }), ); const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { - const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( - Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", - }), - ), - ), - ); - const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( - Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : dependencies.secrets.get(RELAY_URL_SECRET).pipe( - Effect.flatMap((fallbackBytes) => - fallbackBytes - ? Effect.succeed(bytesToString(fallbackBytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", - }), - ), - ), - ), - ), - ); + const cloudMintPublicKey = yield* dependencies.secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* dependencies.secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : dependencies.secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), + ), + ), + ), + ); const environmentId = yield* dependencies.environment.getEnvironmentId; const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); const now = yield* DateTime.now; @@ -911,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud mint JWT.", + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause, }), ), @@ -926,17 +908,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - }), ); export const connectHttpApiLayer = HttpApiBuilder.group( @@ -953,7 +935,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group( .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) .handle("t3MintCredential", ({ payload }) => - traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)), + traceRelayRequest(cloudMintCredentialHandler(dependencies, payload)), ); }), ); diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 4cce901fa55..c46e2671a46 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import { makeCloudCliOAuthConfig, @@ -88,6 +89,27 @@ it.effect("requires Clerk OAuth config when the server bundle has no injected va }).pipe(provideEnv({}), Effect.flip), ); +it.effect("reports malformed Clerk publishable keys as typed configuration failures", () => + Effect.gen(function* () { + const result = yield* makeCloudCliOAuthConfig({ + clerkPublishableKeyFallback: "pk_test_not-base64!!", + clerkCliOAuthClientIdFallback: "oauth_client_embedded", + }).pipe(provideEnv({}), Effect.result); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.cause._tag, "SourceError"); + if (result.failure.cause._tag === "SourceError") { + assert.equal( + result.failure.cause.message, + "Failed to derive Clerk Frontend API URL from the publishable key.", + ); + assert.instanceOf(result.failure.cause.cause, Error); + } + } + }), +); + it("resolves relay client tracing from runtime config with build-time fallback", () => { const fallback = { tracesUrl: "https://embedded.example.test/v1/traces", diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index b344107d756..176b31d7566 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -1,6 +1,7 @@ import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -131,16 +132,29 @@ export function makeCloudCliOAuthConfig({ clerkCliOAuthClientIdFallback, ), }).pipe( - Config.map(({ clerkPublishableKey, clientId }) => { - const clerkFrontendApiUrl = clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey); - return { - authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, - tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, - clientId, - redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, - scopes: CLOUD_CLI_OAUTH_SCOPES, - } satisfies CloudCliOAuthConfig; - }), + Config.mapOrFail(({ clerkPublishableKey, clientId }) => + Effect.try({ + try: () => clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey), + catch: (cause) => + new Config.ConfigError( + new ConfigProvider.SourceError({ + message: "Failed to derive Clerk Frontend API URL from the publishable key.", + cause, + }), + ), + }).pipe( + Effect.map( + (clerkFrontendApiUrl) => + ({ + authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, + tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, + clientId, + redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, + scopes: CLOUD_CLI_OAUTH_SCOPES, + }) satisfies CloudCliOAuthConfig, + ), + ), + ), ); } diff --git a/apps/server/src/cloud/traceRelayRequest.ts b/apps/server/src/cloud/traceRelayRequest.ts new file mode 100644 index 00000000000..1481b891224 --- /dev/null +++ b/apps/server/src/cloud/traceRelayRequest.ts @@ -0,0 +1,21 @@ +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpServerRequest, HttpTraceContext } from "effect/unstable/http"; + +export const traceRelayRequest = ( + effect: Effect.Effect, +): Effect.Effect => effect.pipe(withRelayClientTracing); + +export const traceAuthenticatedRelayRequest = ( + effect: Effect.Effect, +): Effect.Effect => + HttpServerRequest.HttpServerRequest.pipe( + Effect.flatMap((request) => + Option.match(HttpTraceContext.fromHeaders(request.headers), { + onNone: () => effect, + onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)), + }), + ), + withRelayClientTracing, + ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 7defb3763cf..6280c9602c0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,13 +6,13 @@ * * @module ServerConfig */ +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; @@ -49,39 +49,52 @@ export interface ServerDerivedPaths { } /** - * ServerConfigShape - Process/runtime configuration required by the server. + * ServerConfig - Service tag for server runtime configuration. */ -export interface ServerConfigShape extends ServerDerivedPaths { - readonly logLevel: LogLevel.LogLevel; - readonly traceMinLevel: LogLevel.LogLevel; - readonly traceTimingEnabled: boolean; - readonly traceBatchWindowMs: number; - readonly traceMaxBytes: number; - readonly traceMaxFiles: number; - readonly otlpTracesUrl: string | undefined; - readonly otlpMetricsUrl: string | undefined; - readonly otlpExportIntervalMs: number; - readonly otlpServiceName: string; - readonly mode: RuntimeMode; - readonly port: number; - readonly host: string | undefined; - readonly basePath: NormalizedBasePath; - readonly cwd: string; - readonly baseDir: string; - readonly staticDir: string | undefined; - readonly devUrl: URL | undefined; - readonly noBrowser: boolean; - readonly startupPresentation: StartupPresentation; - readonly desktopBootstrapToken: string | undefined; - readonly autoBootstrapProjectFromCwd: boolean; - readonly logWebSocketEvents: boolean; - readonly tailscaleServeEnabled: boolean; - readonly tailscaleServePort: number; +export class ServerConfig extends Context.Service< + ServerConfig, + ServerDerivedPaths & { + readonly logLevel: LogLevel.LogLevel; + readonly traceMinLevel: LogLevel.LogLevel; + readonly traceTimingEnabled: boolean; + readonly traceBatchWindowMs: number; + readonly traceMaxBytes: number; + readonly traceMaxFiles: number; + readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: string | undefined; + readonly otlpExportIntervalMs: number; + readonly otlpServiceName: string; + readonly mode: RuntimeMode; + readonly port: number; + readonly host: string | undefined; + readonly basePath: NormalizedBasePath; + readonly cwd: string; + readonly baseDir: string; + readonly staticDir: string | undefined; + readonly devUrl: URL | undefined; + readonly noBrowser: boolean; + readonly startupPresentation: StartupPresentation; + readonly desktopBootstrapToken: string | undefined; + readonly autoBootstrapProjectFromCwd: boolean; + readonly logWebSocketEvents: boolean; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; + } +>()("t3/config/ServerConfig") { + /** @deprecated Import and use `layerTest` from this module. */ + static readonly layerTest = ( + cwd: string, + baseDirOrPrefix: string | { readonly prefix: string }, + ) => layerTest(cwd, baseDirOrPrefix); } +export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config); + +export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerConfig, make(config)); + export const deriveServerPaths = Effect.fn(function* ( - baseDir: ServerConfigShape["baseDir"], - devUrl: ServerConfigShape["devUrl"], + baseDir: ServerConfig["Service"]["baseDir"], + devUrl: ServerConfig["Service"]["devUrl"], ): Effect.fn.Return { const { join } = yield* Path.Path; const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata"); @@ -135,57 +148,51 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server ); }); -/** - * ServerConfig - Service tag for server runtime configuration. - */ -export class ServerConfig extends Context.Service()( - "t3/config/ServerConfig", +const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( + cwd: string, + baseDirOrPrefix: string | { readonly prefix: string }, ) { - static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) => - Layer.effect( - ServerConfig, - Effect.gen(function* () { - const devUrl = undefined; - - const fs = yield* FileSystem.FileSystem; - const baseDir = - typeof baseDirOrPrefix === "string" - ? baseDirOrPrefix - : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix }); - const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - yield* ensureServerDirectories(derivedPaths); - - return { - logLevel: "Error", - traceMinLevel: "Info", - traceTimingEnabled: true, - traceBatchWindowMs: 200, - traceMaxBytes: 10 * 1024 * 1024, - traceMaxFiles: 10, - otlpTracesUrl: undefined, - otlpMetricsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpServiceName: "t3-server", - cwd, - baseDir, - ...derivedPaths, - mode: "web", - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - port: 0, - host: undefined, - basePath: ROOT_BASE_PATH, - desktopBootstrapToken: undefined, - staticDir: undefined, - devUrl, - noBrowser: false, - startupPresentation: "browser", - } satisfies ServerConfigShape; - }), - ); -} + const devUrl = undefined; + const fs = yield* FileSystem.FileSystem; + const baseDir = + typeof baseDirOrPrefix === "string" + ? baseDirOrPrefix + : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix }); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + yield* ensureServerDirectories(derivedPaths); + + return ServerConfig.of({ + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + cwd, + baseDir, + ...derivedPaths, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + port: 0, + host: undefined, + basePath: ROOT_BASE_PATH, + desktopBootstrapToken: undefined, + staticDir: undefined, + devUrl, + noBrowser: false, + startupPresentation: "browser", + }); +}); + +export const layerTest = (cwd: string, baseDirOrPrefix: string | { readonly prefix: string }) => + Layer.effect(ServerConfig, makeTest(cwd, baseDirOrPrefix)); export const resolveStaticDir = Effect.fn(function* () { const { join, resolve } = yield* Path.Path; diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 18a54326de1..7d16a11c829 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; @@ -219,6 +220,44 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + code: 17, + stdout: "partial process output", + stderr: "process access denied", + }), + ), + ), + ); + + const error = yield* ProcessDiagnostics.readProcessRows.pipe( + Effect.provide(spawnerLayer), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "ProcessDiagnosticsQueryFailedError", + command: "ps", + argCount: 2, + cwd: process.cwd(), + exitCode: 17, + stdoutBytes: 22, + stderrBytes: 21, + stdoutTruncated: false, + stderrTruncated: false, + }); + expect(error.message).toBe( + `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`, + ); + }), + ); + it.effect("does not allow signaling the diagnostics query process", () => Effect.gen(function* () { const spawnerLayer = Layer.succeed( diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index f5f746134f2..b39d560a228 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -12,7 +12,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -31,35 +32,95 @@ const PROCESS_QUERY_TIMEOUT_MS = 1_000; const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; -export interface ProcessDiagnosticsShape { - readonly read: Effect.Effect; - readonly signal: (input: { - readonly pid: number; - readonly signal: ServerProcessSignal; - }) => Effect.Effect; -} - export class ProcessDiagnostics extends Context.Service< ProcessDiagnostics, - ProcessDiagnosticsShape + { + readonly read: Effect.Effect; + readonly signal: (input: { + readonly pid: number; + readonly signal: ServerProcessSignal; + }) => Effect.Effect; + } >()("t3/diagnostics/ProcessDiagnostics") {} -class ProcessDiagnosticsError extends Schema.TaggedErrorClass()( - "ProcessDiagnosticsError", +class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsQueryTimeoutError", { - message: Schema.String, + command: Schema.String, + argCount: Schema.Number, + cwd: Schema.String, + timeoutMillis: Schema.Number, + }, +) { + override get message(): string { + return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`; + } +} + +class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsQueryFailedError", + { + command: Schema.String, + argCount: Schema.Number, + cwd: Schema.String, + exitCode: Schema.optional(Schema.Number), + stdoutBytes: Schema.optional(Schema.Number), + stderrBytes: Schema.optional(Schema.Number), + stdoutTruncated: Schema.optional(Schema.Boolean), + stderrTruncated: Schema.optional(Schema.Boolean), cause: Schema.optional(Schema.Defect()), }, -) {} -const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); +) { + override get message(): string { + const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`; + return `Process diagnostics query '${this.command}' failed${exitCode} in '${this.cwd}'.`; + } +} -function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError { - return new ProcessDiagnosticsError({ - message, - ...(cause === undefined ? {} : { cause }), - }); +class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsServerProcessSignalError", + { pid: Schema.Number }, +) { + override get message(): string { + return "Refusing to signal the T3 server process."; + } } +class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsNotDescendantError", + { + pid: Schema.Number, + serverPid: Schema.Number, + }, +) { + override get message(): string { + return `Process ${this.pid} is not a live descendant of the T3 server.`; + } +} + +class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsSignalFailedError", + { + pid: Schema.Number, + signal: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to signal process ${this.pid} with ${this.signal}.`; + } +} + +const ProcessDiagnosticsError = Schema.Union([ + ProcessDiagnosticsQueryTimeoutError, + ProcessDiagnosticsQueryFailedError, + ProcessDiagnosticsServerProcessSignalError, + ProcessDiagnosticsNotDescendantError, + ProcessDiagnosticsSignalFailedError, +]); +type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; +const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); + function parsePositiveInt(value: string): number | null { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; @@ -266,24 +327,29 @@ function makeResult(input: { } interface ProcessOutput { + readonly cwd: string; readonly exitCode: number; readonly stdout: string; + readonly stdoutBytes: number; + readonly stdoutTruncated: boolean; readonly stderr: string; + readonly stderrBytes: number; + readonly stderrTruncated: boolean; } -const runProcess = Effect.fn("runProcess")( - function* (input: { - readonly command: string; - readonly args: ReadonlyArray; - readonly errorMessage: string; - }) { +const runProcess = Effect.fn("runProcess")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; +}) { + const cwd = process.cwd(); + return yield* Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; // `ps` and `powershell.exe` are real executables; spawning through cmd.exe // shell mode would re-tokenize the PowerShell `-Command` payload (which // contains pipes) before PowerShell ever sees it. const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { - cwd: process.cwd(), + cwd, }), ); const [stdout, stderr, exitCode] = yield* Effect.all( @@ -304,28 +370,44 @@ const runProcess = Effect.fn("runProcess")( ); return { + cwd, exitCode, stdout: stdout.text, + stdoutBytes: stdout.bytes, + stdoutTruncated: stdout.truncated, stderr: stderr.text, + stderrBytes: stderr.bytes, + stderrTruncated: stderr.truncated, } satisfies ProcessOutput; - }, - (effect, input) => - effect.pipe( - Effect.scoped, - Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)), - onSome: Effect.succeed, - }), - ), - Effect.mapError((cause) => - isProcessDiagnosticsError(cause) - ? cause - : toProcessDiagnosticsError(input.errorMessage, cause), - ), + }).pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new ProcessDiagnosticsQueryTimeoutError({ + command: input.command, + argCount: input.args.length, + cwd, + timeoutMillis: PROCESS_QUERY_TIMEOUT_MS, + }), + ), + onSome: Effect.succeed, + }), ), -); + Effect.mapError((cause) => + isProcessDiagnosticsError(cause) + ? cause + : new ProcessDiagnosticsQueryFailedError({ + command: input.command, + argCount: input.args.length, + cwd, + cause, + }), + ), + ); +}); function readPosixProcessRows(): Effect.Effect< ReadonlyArray, @@ -335,11 +417,21 @@ function readPosixProcessRows(): Effect.Effect< return runProcess({ command: "ps", args: ["-axo", POSIX_PROCESS_QUERY_COMMAND], - errorMessage: "Failed to query process diagnostics.", }).pipe( Effect.flatMap((result) => result.exitCode !== 0 - ? Effect.fail(toProcessDiagnosticsError(result.stderr.trim() || "ps failed.")) + ? Effect.fail( + new ProcessDiagnosticsQueryFailedError({ + command: "ps", + argCount: 2, + cwd: result.cwd, + exitCode: result.exitCode, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + }), + ) : Effect.succeed(parsePosixProcessRows(result.stdout)), ), ); @@ -361,12 +453,20 @@ function readWindowsProcessRows(): Effect.Effect< return runProcess({ command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], - errorMessage: "Failed to query process diagnostics.", }).pipe( Effect.flatMap((result) => result.exitCode !== 0 ? Effect.fail( - toProcessDiagnosticsError(result.stderr.trim() || "PowerShell process query failed."), + new ProcessDiagnosticsQueryFailedError({ + command: "powershell.exe", + argCount: 4, + cwd: result.cwd, + exitCode: result.exitCode, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + }), ) : Effect.succeed(parseWindowsProcessRows(result.stdout)), ), @@ -390,7 +490,11 @@ function assertDescendantPid( pid: number, ): Effect.Effect { if (pid === process.pid) { - return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); + return Effect.fail( + new ProcessDiagnosticsServerProcessSignalError({ + pid, + }), + ); } return readProcessRows.pipe( @@ -402,16 +506,19 @@ function assertDescendantPid( return descendant ? Effect.void : Effect.fail( - toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`), + new ProcessDiagnosticsNotDescendantError({ + pid, + serverPid: process.pid, + }), ); }), ); } -export const make = Effect.fn("makeProcessDiagnostics")(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { + const read: ProcessDiagnostics["Service"]["read"] = Effect.gen(function* () { const readAt = yield* DateTime.now; const rows = yield* readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -427,7 +534,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { ), ); - const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")( + const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")( function* (input) { return yield* assertDescendantPid(input.pid).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -443,10 +550,11 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { }; }, catch: (cause) => - toProcessDiagnosticsError( - `Failed to signal process ${input.pid} with ${input.signal}.`, + new ProcessDiagnosticsSignalFailedError({ + pid: input.pid, + signal: input.signal, cause, - ), + }), }), ), Effect.catch((error: ProcessDiagnosticsError) => @@ -464,4 +572,4 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { return ProcessDiagnostics.of({ read, signal }); }); -export const layer = Layer.effect(ProcessDiagnostics, make()); +export const layer = Layer.effect(ProcessDiagnostics, make); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts index 11d12c012db..d9c4eb06ef1 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -3,16 +3,13 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { - aggregateProcessResourceHistory, - collectMonitoredSamples, -} from "./ProcessResourceMonitor.ts"; +import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts"; describe("ProcessResourceMonitor", () => { it.effect("samples the server root process and descendants", () => Effect.sync(() => { const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const samples = collectMonitoredSamples({ + const samples = ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt, sampledAtMs: DateTime.toEpochMillis(sampledAt), @@ -72,7 +69,7 @@ describe("ProcessResourceMonitor", () => { const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z"); const samples = [ - ...collectMonitoredSamples({ + ...ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt: firstAt, sampledAtMs: DateTime.toEpochMillis(firstAt), @@ -89,7 +86,7 @@ describe("ProcessResourceMonitor", () => { }, ], }), - ...collectMonitoredSamples({ + ...ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt: secondAt, sampledAtMs: DateTime.toEpochMillis(secondAt), @@ -108,13 +105,13 @@ describe("ProcessResourceMonitor", () => { }), ]; - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: secondAt, readAtMs: DateTime.toEpochMillis(secondAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(Option.isNone(result.error)).toBe(true); @@ -132,7 +129,7 @@ describe("ProcessResourceMonitor", () => { const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z"); const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z"); const samples = [ - ...collectMonitoredSamples({ + ...ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt: firstAt, sampledAtMs: DateTime.toEpochMillis(firstAt), @@ -149,7 +146,7 @@ describe("ProcessResourceMonitor", () => { }, ], }), - ...collectMonitoredSamples({ + ...ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt: secondAt, sampledAtMs: DateTime.toEpochMillis(secondAt), @@ -168,13 +165,13 @@ describe("ProcessResourceMonitor", () => { }), ]; - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: secondAt, readAtMs: DateTime.toEpochMillis(secondAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(result.topProcesses).toHaveLength(1); @@ -187,7 +184,7 @@ describe("ProcessResourceMonitor", () => { it.effect("returns all process summaries in the selected window", () => Effect.sync(() => { const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const samples = collectMonitoredSamples({ + const samples = ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt, sampledAtMs: DateTime.toEpochMillis(sampledAt), @@ -215,17 +212,44 @@ describe("ProcessResourceMonitor", () => { ], }); - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: sampledAt, readAtMs: DateTime.toEpochMillis(sampledAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(result.topProcesses).toHaveLength(36); expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); }), ); + + it.effect("exposes bounded failure diagnostics while retaining the exact cause", () => + Effect.sync(() => { + const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + const cause = new Error("stderr included credential=secret-value"); + const failure = new ProcessResourceMonitor.ProcessResourceSamplingError({ + failureTag: "ProcessDiagnosticsQueryFailedError", + cause, + }); + + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ + samples: [], + readAt, + readAtMs: DateTime.toEpochMillis(readAt), + windowMs: 60_000, + bucketMs: 10_000, + lastFailure: failure, + }); + + expect(failure.cause).toBe(cause); + expect(Option.getOrThrow(result.error)).toEqual({ + failureTag: "ProcessDiagnosticsQueryFailedError", + message: "Failed to sample process resources (ProcessDiagnosticsQueryFailedError).", + }); + expect(Option.getOrThrow(result.error).message).not.toContain("secret-value"); + }), + ); }); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index efeeb66256d..6030e4172e1 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -1,8 +1,10 @@ -import type { - ServerProcessResourceHistoryBucket, - ServerProcessResourceHistoryInput, - ServerProcessResourceHistoryResult, - ServerProcessResourceHistorySummary, +import { + ServerProcessResourceHistoryFailureTag, + type ServerProcessResourceHistoryBucket, + type ServerProcessResourceHistoryFailureTag as ServerProcessResourceHistoryFailureTagType, + type ServerProcessResourceHistoryInput, + type ServerProcessResourceHistoryResult, + type ServerProcessResourceHistorySummary, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -10,14 +12,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { - buildDescendantEntries, - isDiagnosticsQueryProcess, - type ProcessRow, - readProcessRows, -} from "./ProcessDiagnostics.ts"; +import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; const SAMPLE_INTERVAL_MS = 5_000; const RETENTION_MS = 60 * 60_000; @@ -36,43 +34,58 @@ export interface ProcessResourceSample { readonly isServerRoot: boolean; } -interface MonitorState { - readonly samples: ReadonlyArray; - readonly lastError: string | null; +export class ProcessResourceSamplingError extends Schema.TaggedErrorClass()( + "ProcessResourceSamplingError", + { + failureTag: ServerProcessResourceHistoryFailureTag, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sample process resources (${this.failureTag}).`; + } } -export interface ProcessResourceMonitorShape { - readonly readHistory: ( - input: ServerProcessResourceHistoryInput, - ) => Effect.Effect; +interface MonitorState { + readonly samples: ReadonlyArray; + readonly lastFailure: ProcessResourceSamplingError | null; } export class ProcessResourceMonitor extends Context.Service< ProcessResourceMonitor, - ProcessResourceMonitorShape + { + readonly readHistory: ( + input: ServerProcessResourceHistoryInput, + ) => Effect.Effect; + } >()("t3/diagnostics/ProcessResourceMonitor") {} function dateTimeFromMillis(ms: number): DateTime.Utc { return DateTime.makeUnsafe(ms); } -function sampleKey(row: Pick): string { +function sampleKey(row: Pick): string { return `${row.pid}:${row.command}`; } -function findServerRootRow(rows: ReadonlyArray, serverPid: number): ProcessRow | null { +function findServerRootRow( + rows: ReadonlyArray, + serverPid: number, +): ProcessDiagnostics.ProcessRow | null { return rows.find((row) => row.pid === serverPid) ?? null; } export function collectMonitoredSamples(input: { - readonly rows: ReadonlyArray; + readonly rows: ReadonlyArray; readonly serverPid: number; readonly sampledAt: DateTime.Utc; readonly sampledAtMs: number; }): ReadonlyArray { - const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); + const rows = input.rows.filter( + (row) => !ProcessDiagnostics.isDiagnosticsQueryProcess(row, input.serverPid), + ); const root = findServerRootRow(rows, input.serverPid); - const descendants = buildDescendantEntries(rows, input.serverPid); + const descendants = ProcessDiagnostics.buildDescendantEntries(rows, input.serverPid); const samples: ProcessResourceSample[] = []; if (root) { @@ -220,7 +233,7 @@ export function aggregateProcessResourceHistory(input: { readonly readAtMs: number; readonly windowMs: number; readonly bucketMs: number; - readonly lastError: string | null; + readonly lastFailure: ProcessResourceSamplingError | null; }): ServerProcessResourceHistoryResult { const windowMs = Math.max(1_000, input.windowMs); const bucketMs = Math.max(1_000, input.bucketMs); @@ -241,18 +254,34 @@ export function aggregateProcessResourceHistory(input: { totalCpuSecondsApprox, buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }), topProcesses, - error: input.lastError ? Option.some({ message: input.lastError }) : Option.none(), + error: input.lastFailure + ? Option.some({ + failureTag: input.lastFailure.failureTag, + message: input.lastFailure.message, + }) + : Option.none(), }; } -export const make = Effect.fn("makeProcessResourceMonitor")(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const state = yield* Ref.make({ samples: [], lastError: null }); + const state = yield* Ref.make({ samples: [], lastFailure: null }); + + const recordSamplingFailure = (cause: { + readonly _tag: ServerProcessResourceHistoryFailureTagType; + }) => + Ref.update(state, (current) => ({ + ...current, + lastFailure: new ProcessResourceSamplingError({ + failureTag: cause._tag, + cause, + }), + })); const sampleOnce = Effect.gen(function* () { const sampledAt = yield* DateTime.now; const sampledAtMs = DateTime.toEpochMillis(sampledAt); - const rows = yield* readProcessRows.pipe( + const rows = yield* ProcessDiagnostics.readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const samples = collectMonitoredSamples({ @@ -263,22 +292,23 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { }); yield* Ref.update(state, (current) => ({ samples: trimSamples([...current.samples, ...samples], sampledAtMs), - lastError: null, + lastFailure: null, })); }).pipe( - Effect.catch((error: unknown) => - Ref.update(state, (current) => ({ - ...current, - lastError: error instanceof Error ? error.message : "Failed to sample process resources.", - })), - ), + Effect.catchTags({ + ProcessDiagnosticsQueryTimeoutError: recordSamplingFailure, + ProcessDiagnosticsQueryFailedError: recordSamplingFailure, + ProcessDiagnosticsServerProcessSignalError: recordSamplingFailure, + ProcessDiagnosticsNotDescendantError: recordSamplingFailure, + ProcessDiagnosticsSignalFailedError: recordSamplingFailure, + }), ); yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe( Effect.forkScoped, ); - const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) => + const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => Effect.gen(function* () { const readAt = yield* DateTime.now; const readAtMs = DateTime.toEpochMillis(readAt); @@ -289,11 +319,11 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { readAtMs, windowMs: input.windowMs, bucketMs: input.bucketMs, - lastError: current.lastError, + lastFailure: current.lastFailure, }); }); return ProcessResourceMonitor.of({ readHistory }); }); -export const layer = Layer.effect(ProcessResourceMonitor, make()); +export const layer = Layer.effect(ProcessResourceMonitor, make); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index d4ffa4a5fc2..70bb4dc815c 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -3,8 +3,10 @@ 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 PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; import * as TraceDiagnostics from "./TraceDiagnostics.ts"; @@ -187,18 +189,17 @@ describe("TraceDiagnostics", () => { it.effect("keeps loaded trace data when one rotated trace file fails to read", () => Effect.gen(function* () { const traceFilePath = "/tmp/server.trace.ndjson"; + const readFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: `${traceFilePath}.1`, + }); const fileSystemLayer = FileSystem.layerNoop({ readFileString: (path) => path === `${traceFilePath}.1` - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - description: "permission denied", - pathOrDescriptor: path, - }), - ) + ? Effect.fail(readFailure) : Effect.succeed( record({ name: "server.getConfig", @@ -209,20 +210,44 @@ describe("TraceDiagnostics", () => { }), ), }); + const logAnnotations: Array> = []; + const logger = Logger.make((options) => { + logAnnotations.push({ ...options.fiber.getRef(References.CurrentLogAnnotations) }); + }); const diagnostics = yield* TraceDiagnostics.readTraceDiagnostics({ traceFilePath, maxFiles: 1, readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - }).pipe(Effect.provide(TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)))); + }).pipe( + Effect.provide( + Layer.mergeAll( + TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); assert.equal(diagnostics.recordCount, 1); assert.equal( Option.getOrElse(diagnostics.partialFailure, () => false), true, ); - assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-read-failed"); + assert.deepStrictEqual(Option.getOrUndefined(diagnostics.error), { + kind: "trace-file-read-failed", + message: `Failed to read local trace file '${traceFilePath}.1'.`, + }); assert.deepStrictEqual(diagnostics.scannedFilePaths, [`${traceFilePath}.1`, traceFilePath]); + + const failureLog = logAnnotations.find( + (annotations) => annotations.traceFilePath === `${traceFilePath}.1`, + ); + assert.exists(failureLog); + assert.deepStrictEqual(failureLog, { + traceFilePath: `${traceFilePath}.1`, + errorTag: "TraceFileReadError", + causeTag: "PermissionDenied", + }); }), ); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index ff63410b9bc..d54e033380c 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -14,6 +14,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; interface TraceRecordLike { readonly name?: unknown; @@ -39,13 +41,27 @@ export interface TraceDiagnosticsOptions { readonly readAt?: DateTime.Utc; } -export interface TraceDiagnosticsShape { - readonly read: (options: TraceDiagnosticsOptions) => Effect.Effect; +export class TraceFileReadError extends Schema.TaggedErrorClass()( + "TraceFileReadError", + { + traceFilePath: Schema.String, + causeTag: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read local trace file '${this.traceFilePath}'.`; + } } -export class TraceDiagnostics extends Context.Service()( - "t3/diagnostics/TraceDiagnostics", -) {} +export class TraceDiagnostics extends Context.Service< + TraceDiagnostics, + { + readonly read: ( + options: TraceDiagnosticsOptions, + ) => Effect.Effect; + } +>()("t3/diagnostics/TraceDiagnostics") {} interface TraceDiagnosticsInput { readonly traceFilePath: string; @@ -152,10 +168,6 @@ function isNotFoundError(error: PlatformError.PlatformError): boolean { return error.reason._tag === "NotFound"; } -function platformErrorMessage(error: PlatformError.PlatformError): string { - return error.message || String(error); -} - function insertBoundedSlowestSpan( slowestSpans: ServerTraceDiagnosticsSpanOccurrence[], span: ServerTraceDiagnosticsSpanOccurrence, @@ -376,47 +388,66 @@ export function aggregateTraceDiagnostics( type TraceFileReadResult = | { readonly _tag: "Loaded"; readonly path: string; readonly text: string } - | { readonly _tag: "Missing"; readonly path: string } - | { readonly _tag: "Failed"; readonly path: string; readonly message: string }; + | { readonly _tag: "Missing"; readonly path: string }; function readTraceFile( fileSystem: FileSystem.FileSystem, path: string, -): Effect.Effect { +): Effect.Effect { return fileSystem.readFileString(path).pipe( - Effect.map((text) => ({ _tag: "Loaded" as const, path, text })), - Effect.catch((error: PlatformError.PlatformError) => - Effect.succeed( - isNotFoundError(error) - ? { _tag: "Missing" as const, path } - : { _tag: "Failed" as const, path, message: platformErrorMessage(error) }, - ), - ), + Effect.map((text): TraceFileReadResult => ({ _tag: "Loaded", path, text })), + Effect.catchTags({ + PlatformError: (cause) => + isNotFoundError(cause) + ? Effect.succeed({ _tag: "Missing", path }) + : Effect.fail( + new TraceFileReadError({ + traceFilePath: path, + causeTag: cause.reason._tag, + cause, + }), + ), + }), ); } -export const make = Effect.fn("makeTraceDiagnostics")(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const read: TraceDiagnosticsShape["read"] = Effect.fn("TraceDiagnostics.read")( + const read: TraceDiagnostics["Service"]["read"] = Effect.fn("TraceDiagnostics.read")( function* (options) { const readAt = options.readAt ?? (yield* DateTime.now); const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); const results = yield* Effect.all( - paths.map((path) => readTraceFile(fileSystem, path)), + paths.map((path) => + readTraceFile(fileSystem, path).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to read local trace file.").pipe( + Effect.annotateLogs({ + traceFilePath: cause.traceFilePath, + errorTag: cause._tag, + causeTag: cause.causeTag, + }), + ), + ), + Effect.result, + ), + ), { concurrency: 1, }, ); const files = results.flatMap((result) => - result._tag === "Loaded" ? [{ path: result.path, text: result.text }] : [], + Result.isSuccess(result) && result.success._tag === "Loaded" + ? [{ path: result.success.path, text: result.success.text }] + : [], ); - const readFailure = results.find((result) => result._tag === "Failed"); + const readFailure = results.find(Result.isFailure); const readFailureError = readFailure ? ({ kind: "trace-file-read-failed", - message: readFailure.message.trim() || `Failed to read ${readFailure.path}.`, + message: readFailure.failure.message, } satisfies TraceDiagnosticsErrorSummary) : undefined; @@ -449,7 +480,7 @@ export const make = Effect.fn("makeTraceDiagnostics")(function* () { return TraceDiagnostics.of({ read }); }); -export const layer = Layer.effect(TraceDiagnostics, make()); +export const layer = Layer.effect(TraceDiagnostics, make); export function readTraceDiagnostics( options: TraceDiagnosticsOptions, diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts deleted file mode 100644 index 04c4b7157ca..00000000000 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as nodePath from "node:path"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; - -import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; -import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts"; -import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; -import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; - -const makeServerEnvironmentLayer = (baseDir: string) => - ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); - -const makeServerConfig = Effect.fn(function* (baseDir: string) { - const derivedPaths = yield* deriveServerPaths(baseDir, undefined); - - return { - ...derivedPaths, - logLevel: "Error", - traceMinLevel: "Info", - traceTimingEnabled: true, - traceBatchWindowMs: 200, - traceMaxBytes: 10 * 1024 * 1024, - traceMaxFiles: 10, - otlpTracesUrl: undefined, - otlpMetricsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpServiceName: "t3-server", - cwd: process.cwd(), - baseDir, - mode: "web", - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - basePath: ROOT_BASE_PATH, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - port: 0, - host: undefined, - desktopBootstrapToken: undefined, - staticDir: undefined, - devUrl: undefined, - noBrowser: false, - startupPresentation: "browser", - } satisfies ServerConfigShape; -}); - -it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { - it.effect("persists the environment id across service restarts", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-environment-test-", - }); - - const first = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); - const second = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); - - expect(first.environmentId).toBe(second.environmentId); - expect(second.capabilities.repositoryIdentity).toBe(true); - }), - ); - - it.effect("fails instead of overwriting a persisted id when reading the file errors", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-environment-read-error-test-", - }); - const serverConfig = yield* makeServerConfig(baseDir); - const environmentIdPath = serverConfig.environmentIdPath; - yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); - yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); - const writeAttempts: string[] = []; - const failingFileSystemLayer = FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === environmentIdPath), - readFileString: (path) => - path === environmentIdPath - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - description: "permission denied", - pathOrDescriptor: path, - }), - ) - : Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFileString", - description: "not found", - pathOrDescriptor: path, - }), - ), - writeFileString: (path) => { - writeAttempts.push(path); - return Effect.void; - }, - }); - - const exit = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe( - Effect.provide( - ServerEnvironmentLive.pipe( - Layer.provide( - Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), - ), - ), - ), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - expect(writeAttempts).toEqual([]); - expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( - "persisted-environment-id\n", - ); - }), - ); -}); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts deleted file mode 100644 index fd4f6baab1a..00000000000 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { ServerConfig } from "../../config.ts"; -import { layer as ProcessRunnerLive } from "../../processRunner.ts"; -import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; -import packageJson from "../../../package.json" with { type: "json" }; -import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; - -function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { - switch (platform) { - case "darwin": - return "darwin"; - case "linux": - return "linux"; - case "win32": - return "windows"; - default: - return "unknown"; - } -} - -function platformArch( - architecture: NodeJS.Architecture, -): ExecutionEnvironmentDescriptor["platform"]["arch"] { - switch (architecture) { - case "arm64": - return "arm64"; - case "x64": - return "x64"; - default: - return "other"; - } -} - -export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; - const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; - - const readPersistedEnvironmentId = Effect.gen(function* () { - const exists = yield* fileSystem - .exists(serverConfig.environmentIdPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return null; - } - - const raw = yield* fileSystem - .readFileString(serverConfig.environmentIdPath) - .pipe(Effect.map((value) => value.trim())); - - return raw.length > 0 ? raw : null; - }); - - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); - - const environmentIdRaw = yield* Effect.gen(function* () { - const persisted = yield* readPersistedEnvironmentId; - if (persisted) { - return persisted; - } - - const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; - }); - - const environmentId = EnvironmentId.make(environmentIdRaw); - const cwdBaseName = path.basename(serverConfig.cwd).trim(); - const label = yield* resolveServerEnvironmentLabel({ - cwdBaseName, - }); - - const descriptor: ExecutionEnvironmentDescriptor = { - environmentId, - label, - platform: { - os: platformOs(hostPlatform), - arch: platformArch(hostArchitecture), - }, - serverVersion: packageJson.version, - capabilities: { - repositoryIdentity: true, - }, - }; - - return { - getEnvironmentId: Effect.succeed(environmentId), - getDescriptor: Effect.succeed(descriptor), - } satisfies ServerEnvironmentShape; -}); - -export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe( - Layer.provide(ProcessRunnerLive), -); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts deleted file mode 100644 index 3a4dce1627c..00000000000 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { vi } from "vite-plus/test"; - -import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; -import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -const runMock = vi.fn(); - -const ProcessRunnerTest = Layer.succeed( - ProcessRunner, - ProcessRunner.of({ - run: (input) => runMock(input), - }), -); -const NoopFileSystemLayer = FileSystem.layerNoop({}); -const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest); -const LinuxMachineInfoLayer = Layer.merge( - ProcessRunnerTest, - FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === "/etc/machine-info"), - readFileString: (path) => - path === "/etc/machine-info" - ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') - : Effect.succeed(""), - }), -); -const withHostPlatform = ( - layer: Layer.Layer, - platform: NodeJS.Platform, - hostname: string, -) => - Layer.mergeAll( - layer, - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessHostname, hostname), - ); - -afterEach(() => { - runMock.mockReset(); -}); - -describe("resolveServerEnvironmentLabel", () => { - it.effect("uses hostname fallback regardless of launch mode", () => - Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); - - expect(result).toBe("macbook-pro"); - }), - ); - - it.effect("prefers the macOS ComputerName", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: " Julius's MacBook Pro \n", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }), - ); - - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); - - expect(result).toBe("Julius's MacBook Pro"); - expect(runMock).toHaveBeenCalledWith( - expect.objectContaining({ - command: "scutil", - args: ["--get", "ComputerName"], - timeoutBehavior: "timedOutResult", - }), - ); - }), - ); - - it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => - Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); - - expect(result).toBe("Build Agent 01"); - expect(runMock).not.toHaveBeenCalled(); - }), - ); - - it.effect("falls back to hostnamectl pretty hostname on Linux", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: "CI Runner\n", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }), - ); - - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); - - expect(result).toBe("CI Runner"); - expect(runMock).toHaveBeenCalledWith( - expect.objectContaining({ - command: "hostnamectl", - args: ["--pretty"], - timeoutBehavior: "timedOutResult", - }), - ); - }), - ); - - it.effect("falls back to the hostname when friendly labels are unavailable", () => - Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); - - expect(result).toBe("JULIUS-LAPTOP"); - }), - ); - - it.effect("falls back to the hostname when the friendly-label command is missing", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.fail( - new ProcessSpawnError({ - command: "scutil", - args: ["--get", "ComputerName"], - cause: new Error("spawn scutil ENOENT"), - }), - ), - ); - - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); - - expect(result).toBe("macbook-pro"); - }), - ); - - it.effect("falls back to the cwd basename when the hostname is blank", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: " ", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }), - ); - - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); - - expect(result).toBe("t3code"); - }), - ); -}); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts deleted file mode 100644 index 73a3b9526c4..00000000000 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; - -import { ProcessRunner } from "../../processRunner.ts"; - -interface ResolveServerEnvironmentLabelInput { - readonly cwdBaseName: string; -} - -function normalizeLabel(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : null; -} - -function parseMachineInfoValue(raw: string, key: string): string | null { - for (const line of raw.split(/\r?\n/g)) { - const trimmed = line.trim(); - if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { - continue; - } - const value = trimmed.slice(key.length + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return normalizeLabel(value.slice(1, -1)); - } - return normalizeLabel(value); - } - return null; -} - -const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fileSystem - .exists("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return null; - } - - return yield* fileSystem - .readFileString("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => null)); -}); - -const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( - command: string, - args: readonly string[], -) { - const processRunner = yield* ProcessRunner; - const result = yield* processRunner - .run({ - command, - args, - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.option); - - if (Option.isNone(result) || result.value.code !== 0) { - return null; - } - - return normalizeLabel(result.value.stdout); -}); - -const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { - const platform = yield* HostProcessPlatform; - if (platform === "darwin") { - return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); - } - - if (platform === "linux") { - const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); - if (machineInfo) { - const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); - if (prettyHostname) { - return prettyHostname; - } - } - - return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); - } - - return null; -}); - -export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( - input: ResolveServerEnvironmentLabelInput, -) { - const friendlyHostLabel = yield* resolveFriendlyHostLabel(); - if (friendlyHostLabel) { - return friendlyHostLabel; - } - - const hostname = normalizeLabel(yield* HostProcessHostname); - if (hostname) { - return hostname; - } - - return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; -}); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts new file mode 100644 index 00000000000..1aac2bb0d41 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -0,0 +1,134 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; + +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "./ServerEnvironment.ts"; + +const isServerEnvironmentIdPersistenceError = Schema.is( + ServerEnvironment.ServerEnvironmentIdPersistenceError, +); + +const makeServerEnvironmentLayer = (baseDir: string) => + ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + +const makeServerConfig = Effect.fn(function* (baseDir: string) { + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + + return { + ...derivedPaths, + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + cwd: process.cwd(), + baseDir, + basePath: ROOT_BASE_PATH, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + port: 0, + host: undefined, + desktopBootstrapToken: undefined, + staticDir: undefined, + devUrl: undefined, + noBrowser: false, + startupPresentation: "browser", + } satisfies ServerConfig.ServerConfig["Service"]; +}); + +it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect("persists the environment id across service restarts", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-test-", + }); + + const first = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + const second = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + + expect(first.environmentId).toBe(second.environmentId); + expect(second.capabilities.repositoryIdentity).toBe(true); + }), + ); + + it.effect("structures persisted environment id filesystem failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-error-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + const environmentIdPath = serverConfig.environmentIdPath; + const methodByOperation = { + check: "exists", + read: "readFileString", + write: "writeFileString", + } as const; + + for (const operation of ["check", "read", "write"] as const) { + const writeAttempts: string[] = []; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: methodByOperation[operation], + description: "permission denied", + pathOrDescriptor: environmentIdPath, + }); + const failingFileSystemLayer = FileSystem.layerNoop({ + exists: () => + operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), + readFileString: () => Effect.fail(cause), + writeFileString: (path) => { + writeAttempts.push(path); + return Effect.fail(cause); + }, + }); + + const error = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), + ), + ), + Effect.flip, + ); + + expect(isServerEnvironmentIdPersistenceError(error)).toBe(true); + if (!isServerEnvironmentIdPersistenceError(error)) { + throw error; + } + expect(error.operation).toBe(operation); + expect(error.environmentIdPath).toBe(environmentIdPath); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + `Server environment ID ${operation} failed at '${environmentIdPath}'.`, + ); + expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); + } + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts new file mode 100644 index 00000000000..b5fbd8e1088 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -0,0 +1,152 @@ +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; + +export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( + "ServerEnvironmentIdPersistenceError", + { + operation: Schema.Literals(["check", "read", "write"]), + environmentIdPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; + } +} + +export class ServerEnvironment extends Context.Service< + ServerEnvironment, + { + readonly getEnvironmentId: Effect.Effect; + readonly getDescriptor: Effect.Effect; + } +>()("t3/environment/ServerEnvironment") {} + +function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { + switch (platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +function platformArch( + architecture: NodeJS.Architecture, +): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (architecture) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + return "other"; + } +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const crypto = yield* Crypto.Crypto; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + + const readPersistedEnvironmentId = Effect.gen(function* () { + const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "check", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); + if (!exists) { + return null; + } + + const raw = yield* fileSystem.readFileString(serverConfig.environmentIdPath).pipe( + Effect.map((value) => value.trim()), + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "read", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); + + return raw.length > 0 ? raw : null; + }); + + const persistEnvironmentId = (value: string) => + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); + + const environmentIdRaw = yield* Effect.gen(function* () { + const persisted = yield* readPersistedEnvironmentId; + if (persisted) { + return persisted; + } + + const generated = yield* crypto.randomUUIDv4; + yield* persistEnvironmentId(generated); + return generated; + }); + + const environmentId = EnvironmentId.make(environmentIdRaw); + const cwdBaseName = path.basename(serverConfig.cwd).trim(); + const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + + const descriptor: ExecutionEnvironmentDescriptor = { + environmentId, + label, + platform: { + os: platformOs(hostPlatform), + arch: platformArch(hostArchitecture), + }, + serverVersion: packageJson.version, + capabilities: { + repositoryIdentity: true, + }, + }; + + return ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }); +}); + +/** + * ServerEnvironment is acquired from persisted filesystem and host-process + * state. It intentionally has no fallback Layer.succeed value: callers must + * provide the external platform services and a ServerConfig. + */ +export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts new file mode 100644 index 00000000000..b5bb8a8ff1c --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +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 PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { vi } from "vite-plus/test"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as ServerEnvironmentLabel from "./ServerEnvironmentLabel.ts"; + +const isServerEnvironmentLabelFileError = Schema.is( + ServerEnvironmentLabel.ServerEnvironmentLabelFileError, +); +const isServerEnvironmentLabelCommandError = Schema.is( + ServerEnvironmentLabel.ServerEnvironmentLabelCommandError, +); + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} + +const runMock = vi.fn(); + +const ProcessRunnerTest = Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => runMock(input), + }), +); +const NoopFileSystemLayer = FileSystem.layerNoop({}); +const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest); +const LinuxMachineInfoLayer = Layer.merge( + ProcessRunnerTest, + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === "/etc/machine-info"), + readFileString: (path) => + path === "/etc/machine-info" + ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') + : Effect.succeed(""), + }), +); +const withHostPlatform = ( + layer: Layer.Layer, + platform: NodeJS.Platform, + hostname: string, +) => + Layer.mergeAll( + layer, + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessHostname, hostname), + ); + +afterEach(() => { + runMock.mockReset(); +}); + +describe("resolveServerEnvironmentLabel", () => { + it.effect("uses hostname fallback regardless of launch mode", () => + Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); + + expect(result).toBe("macbook-pro"); + }), + ); + + it.effect("prefers the macOS ComputerName", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: " Julius's MacBook Pro \n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); + + expect(result).toBe("Julius's MacBook Pro"); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ + command: "scutil", + args: ["--get", "ComputerName"], + timeoutBehavior: "timedOutResult", + }), + ); + }), + ); + + it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => + Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); + + expect(result).toBe("Build Agent 01"); + expect(runMock).not.toHaveBeenCalled(); + }), + ); + + it.effect("falls back to hostnamectl pretty hostname on Linux", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: "CI Runner\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); + + expect(result).toBe("CI Runner"); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ + command: "hostnamectl", + args: ["--pretty"], + timeoutBehavior: "timedOutResult", + }), + ); + }), + ); + + it.effect("falls back to the hostname when friendly labels are unavailable", () => + Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); + + expect(result).toBe("JULIUS-LAPTOP"); + }), + ); + + it.effect("falls back to the hostname when the friendly-label command is missing", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + const spawnCause = new Error("spawn scutil ENOENT"); + const processError = new ProcessRunner.ProcessSpawnError({ + command: "scutil", + argumentCount: 2, + cause: spawnCause, + }); + runMock.mockReturnValueOnce(Effect.fail(processError)); + + return Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }); + + expect(result).toBe("macbook-pro"); + expect(logs[0]?.message).toEqual([ + "Failed to run environment-label probe 'macos-computer-name' with scutil.", + ]); + const error = logs[0]?.annotations.cause; + expect(isServerEnvironmentLabelCommandError(error)).toBe(true); + if (isServerEnvironmentLabelCommandError(error)) { + expect(error.probe).toBe("macos-computer-name"); + expect(error.executable).toBe("scutil"); + expect(error.argumentCount).toBe(2); + expect(error).not.toHaveProperty("args"); + expect(error.message).not.toContain("--get"); + expect(error.message).not.toContain("ComputerName"); + expect(error.cause).toBe(processError); + expect(processError.cause).toBe(spawnCause); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + withHostPlatform(TestLayer, "darwin", "macbook-pro"), + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); + + it.effect("continues to hostnamectl after a machine-info inspect failure", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + const fileCause = new Error("permission denied"); + const platformError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: "/etc/machine-info", + cause: fileCause, + }); + const fileSystemLayer = FileSystem.layerNoop({ + exists: () => Effect.fail(platformError), + }); + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: "CI Runner\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + return Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }); + + expect(result).toBe("CI Runner"); + expect(logs[0]?.message).toEqual([ + "Failed to inspect environment-label file at /etc/machine-info.", + ]); + const error = logs[0]?.annotations.cause; + expect(isServerEnvironmentLabelFileError(error)).toBe(true); + if (isServerEnvironmentLabelFileError(error)) { + expect(error.operation).toBe("inspect"); + expect(error.path).toBe("/etc/machine-info"); + expect(error.cause).toBe(platformError); + expect(platformError.cause).toBe(fileCause); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + withHostPlatform(Layer.merge(ProcessRunnerTest, fileSystemLayer), "linux", "buildbox"), + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); + + it.effect("falls back to the cwd basename when the hostname is blank", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: " ", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); + + expect(result).toBe("t3code"); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts new file mode 100644 index 00000000000..bd034e0fa26 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentLabel.ts @@ -0,0 +1,196 @@ +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ProcessRunner from "../processRunner.ts"; + +interface ResolveServerEnvironmentLabelInput { + readonly cwdBaseName: string; +} + +const ServerEnvironmentLabelCommandProbe = Schema.Literals([ + "macos-computer-name", + "linux-pretty-hostname", +]); +type ServerEnvironmentLabelCommandProbe = typeof ServerEnvironmentLabelCommandProbe.Type; + +export class ServerEnvironmentLabelFileError extends Schema.TaggedErrorClass()( + "ServerEnvironmentLabelFileError", + { + operation: Schema.Literals(["inspect", "read"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} environment-label file at ${this.path}.`; + } +} + +export class ServerEnvironmentLabelCommandError extends Schema.TaggedErrorClass()( + "ServerEnvironmentLabelCommandError", + { + probe: ServerEnvironmentLabelCommandProbe, + executable: Schema.String, + argumentCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to run environment-label probe '${this.probe}' with ${this.executable}.`; + } +} + +function normalizeLabel(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function parseMachineInfoValue(raw: string, key: string): string | null { + for (const line of raw.split(/\r?\n/g)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { + continue; + } + const value = trimmed.slice(key.length + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return normalizeLabel(value.slice(1, -1)); + } + return normalizeLabel(value); + } + return null; +} + +const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const machineInfoPath = "/etc/machine-info"; + return yield* fileSystem.exists(machineInfoPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelFileError({ + operation: "inspect", + path: machineInfoPath, + cause, + }), + ), + Effect.flatMap((exists) => + exists + ? fileSystem.readFileString(machineInfoPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelFileError({ + operation: "read", + path: machineInfoPath, + cause, + }), + ), + ) + : Effect.succeed(null), + ), + Effect.catchTags({ + ServerEnvironmentLabelFileError: (error) => + Effect.logDebug(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + path: error.path, + cause: error, + }), + Effect.as(null), + ), + }), + ); +}); + +const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* (input: { + readonly probe: ServerEnvironmentLabelCommandProbe; + readonly command: string; + readonly args: readonly string[]; +}) { + const processRunner = yield* ProcessRunner.ProcessRunner; + const result = yield* processRunner + .run({ + command: input.command, + args: input.args, + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelCommandError({ + probe: input.probe, + executable: input.command, + argumentCount: input.args.length, + cause, + }), + ), + Effect.map(Option.some), + Effect.catchTags({ + ServerEnvironmentLabelCommandError: (error) => + Effect.logDebug(error.message).pipe( + Effect.annotateLogs({ + probe: error.probe, + executable: error.executable, + argumentCount: error.argumentCount, + cause: error, + }), + Effect.as(Option.none()), + ), + }), + ); + + if (Option.isNone(result) || result.value.code !== 0) { + return null; + } + + return normalizeLabel(result.value.stdout); +}); + +const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { + const platform = yield* HostProcessPlatform; + if (platform === "darwin") { + return yield* runFriendlyLabelCommand({ + probe: "macos-computer-name", + command: "scutil", + args: ["--get", "ComputerName"], + }); + } + + if (platform === "linux") { + const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); + if (machineInfo) { + const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); + if (prettyHostname) { + return prettyHostname; + } + } + + return yield* runFriendlyLabelCommand({ + probe: "linux-pretty-hostname", + command: "hostnamectl", + args: ["--pretty"], + }); + } + + return null; +}); + +export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( + input: ResolveServerEnvironmentLabelInput, +) { + const friendlyHostLabel = yield* resolveFriendlyHostLabel(); + if (friendlyHostLabel) { + return friendlyHostLabel; + } + + const hostname = normalizeLabel(yield* HostProcessHostname); + if (hostname) { + return hostname; + } + + return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; +}); diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts deleted file mode 100644 index 1e6dea0d05f..00000000000 --- a/apps/server/src/environment/Services/ServerEnvironment.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export interface ServerEnvironmentShape { - readonly getEnvironmentId: Effect.Effect; - readonly getDescriptor: Effect.Effect; -} - -export class ServerEnvironment extends Context.Service()( - "t3/environment/Services/ServerEnvironment", -) {} diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index bee861677a5..e1924c03ade 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -20,27 +20,16 @@ import type { } from "@t3tools/contracts"; import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; -import { type GitManagerShape } from "./GitManager.ts"; -import { - GitHubCliError, - type GitHubCliShape, - type GitHubPullRequestSummary, - GitHubCli, -} from "../sourceControl/GitHubCli.ts"; -import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import { makeGitManager } from "./GitManager.ts"; -import { ServerConfig } from "../config.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; -import { - ProjectSetupScriptRunner, - ProjectSetupScriptRunnerError, - type ProjectSetupScriptRunnerInput, - type ProjectSetupScriptRunnerShape, -} from "../project/Services/ProjectSetupScriptRunner.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitManager from "./GitManager.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -60,7 +49,7 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubCliError; + failWith?: GitHubCli.GitHubCliError; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { @@ -108,7 +97,7 @@ interface FakeGitTextGeneration { type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { +function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -175,25 +164,15 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary } function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { - const result = spawnSync("git", args, { + const result = NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8", }); if (result.status === 0) { return; } - throw new GitHubCliError({ - operation: "execute", - detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, - }); -} - -function isGitHubCliError(error: unknown): error is GitHubCliError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - (error as { _tag?: unknown })._tag === "GitHubCliError" + throw new Error( + `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, ); } @@ -265,7 +244,7 @@ function initRepo( yield* runGit(cwd, ["init", "--initial-branch=main"]); yield* runGit(cwd, ["config", "user.email", "test@example.com"]); yield* runGit(cwd, ["config", "user.name", "Test User"]); - yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); + yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n"); yield* runGit(cwd, ["add", "README.md"]); yield* runGit(cwd, ["commit", "-m", "Initial commit"]); }); @@ -312,7 +291,9 @@ function configureVisibleRemoteUrlWithLocalRewrite( }); } -function createTextGeneration(overrides: Partial = {}): TextGenerationShape { +function createTextGeneration( + overrides: Partial = {}, +): TextGeneration.TextGeneration["Service"] { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => Effect.succeed({ @@ -385,7 +366,7 @@ function createTextGeneration(overrides: Partial = {}): T } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCliShape; + service: GitHubCli.GitHubCli["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -397,7 +378,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - const execute: GitHubCliShape["execute"] = (input) => { + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -468,7 +449,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { try: () => { const headBranch = scenario.pullRequest?.headRefName; if (headBranch) { - const existingBranch = spawnSync( + const existingBranch = NodeChildProcess.spawnSync( "git", ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], { @@ -485,14 +466,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - isGitHubCliError(error) + GitHubCli.isGitHubCliError(error) ? error - : new GitHubCliError({ - operation: "execute", - detail: - error instanceof Error - ? `Failed to simulate gh checkout: ${error.message}` - : "Failed to simulate gh checkout.", + : new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: error, }), }); } @@ -503,9 +482,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCliError({ - operation: "execute", - detail: `Unexpected repository lookup: ${repository}`, + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected repository lookup: ${repository}`), }), ); } @@ -523,9 +503,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCliError({ - operation: "execute", - detail: `Unexpected gh command: ${args.join(" ")}`, + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected gh command: ${args.join(" ")}`), }), ); }; @@ -553,7 +534,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.map((raw) => raw .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubPullRequestSummary => entry !== null), + .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), ), ), createPullRequest: (input) => @@ -592,7 +573,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], - }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), + ), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, @@ -600,9 +583,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCliError({ - operation: "createRepository", - detail: `Unexpected repository create: ${input.repository}`, + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected repository create: ${input.repository}`), }), ), checkoutPullRequest: (input) => @@ -616,7 +600,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } function runStackedAction( - manager: GitManagerShape, + manager: GitManager.GitManager["Service"], input: { cwd: string; action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; @@ -625,7 +609,7 @@ function runStackedAction( featureBranch?: boolean; filePaths?: readonly string[]; }, - options?: Parameters[1], + options?: Parameters[1], ) { return manager.runStackedAction( { @@ -636,12 +620,15 @@ function runStackedAction( ); } -function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { +function resolvePullRequest( + manager: GitManager.GitManager["Service"], + input: { cwd: string; reference: string }, +) { return manager.resolvePullRequest(input); } function preparePullRequestThread( - manager: GitManagerShape, + manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); @@ -650,24 +637,24 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; - setupScriptRunner?: ProjectSetupScriptRunnerShape; + setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); - const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", }); - const serverSettingsLayer = ServerSettingsService.layerTest(); + const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const vcsDriverLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(serverConfigLayer), ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make().pipe( + GitHubSourceControlProvider.make.pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -676,14 +663,14 @@ function makeManager(input?: { discover: Effect.succeed([]), }), ), - Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), + Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)), ), ); const managerLayer = Layer.mergeAll( - Layer.succeed(TextGeneration, textGeneration), + Layer.succeed(TextGeneration.TextGeneration, textGeneration), Layer.succeed( - ProjectSetupScriptRunner, + ProjectSetupScriptRunner.ProjectSetupScriptRunner, input?.setupScriptRunner ?? { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, @@ -692,7 +679,7 @@ function makeManager(input?: { serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); - return makeGitManager().pipe( + return GitManager.make.pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); @@ -920,7 +907,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); - const cwd = path.join(rootDir, "deleted-repo"); + const cwd = NodePath.join(rootDir, "deleted-repo"); yield* makeDirectory(cwd); yield* removePath(cwd); const { manager } = yield* makeManager(); @@ -1030,7 +1017,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-pr.txt"), "fork pr\n"); yield* runGit(repoDir, ["add", "fork-pr.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); @@ -1335,9 +1322,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI (`gh`) is required but not available on PATH.", + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), }), }, }); @@ -1352,7 +1340,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1387,7 +1375,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1430,8 +1418,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); - fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "a.txt"), "file a\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "b.txt"), "file b\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1458,7 +1446,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nfeature-branch\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1518,7 +1506,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom-feature\n"); let generatedCount = 0; const { manager } = yield* makeManager({ @@ -1581,16 +1569,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* initRepo(repoDir); const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); + }).pipe(Effect.flip); - expect(errorMessage).toContain("no changes to commit"); + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "runFeatureBranchStep", + cwd: repoDir, + }); + expect(error.message).toContain("no changes to commit"); }), ); @@ -1601,7 +1591,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1630,7 +1620,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -1701,7 +1691,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "push-only.txt"), "push only\n"); yield* runGit(repoDir, ["add", "push-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); @@ -1729,11 +1719,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "push-dirty.txt"), "push dirty\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "push-dirty.txt"), "push dirty\n"); yield* runGit(repoDir, ["add", "push-dirty.txt"]); yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]); - fs.mkdirSync(path.join(repoDir, ".vercel")); - fs.writeFileSync(path.join(repoDir, ".vercel", "project.json"), "{}\n"); + NodeFS.mkdirSync(NodePath.join(repoDir, ".vercel")); + NodeFS.writeFileSync(NodePath.join(repoDir, ".vercel", "project.json"), "{}\n"); const { manager } = yield* makeManager(); const result = yield* runStackedAction(manager, { @@ -1764,7 +1754,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); @@ -1809,7 +1799,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); - fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "provider-fallback.txt"), "fallback\n"); yield* runGit(repoDir, ["add", "provider-fallback.txt"]); yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); const remoteDir = yield* createBareRemote(); @@ -1986,7 +1976,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); @@ -2204,7 +2194,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); @@ -2243,6 +2233,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("generates PR content against the remote base when the local base is stale", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/main"]); + + const peerDir = yield* makeTempDir("t3code-git-peer-"); + yield* runGit(peerDir, ["clone", remoteDir, "."]); + yield* runGit(peerDir, ["config", "user.email", "peer@example.com"]); + yield* runGit(peerDir, ["config", "user.name", "Peer User"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "remote.txt"), "remote\n"); + yield* runGit(peerDir, ["add", "remote.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Remote base commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, [ + "checkout", + "--no-track", + "-b", + "feature/remote-base", + "origin/main", + ]); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + yield* runGit(repoDir, ["add", "feature.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); + yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + + let generatedCommitSummary = ""; + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: ["[]", "[]"], + }, + textGeneration: { + generatePrContent: (input) => { + generatedCommitSummary = input.commitSummary; + return Effect.succeed({ title: "Feature PR", body: "Feature body" }); + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(generatedCommitSummary).toContain("Feature commit"); + expect(generatedCommitSummary).not.toContain("Remote base commit"); + }), + ); + it.effect( "creates a new PR instead of reusing an unrelated fork PR with the same head branch", () => @@ -2252,7 +2298,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); @@ -2324,7 +2370,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); yield* runGit(repoDir, ["add", "changes.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); @@ -2417,9 +2463,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI (`gh`) is required but not available on PATH.", + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), }), }, }); @@ -2446,9 +2493,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + failWith: new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not authenticated"), }), }, }); @@ -2504,7 +2552,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); - fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "local.txt"), "local\n"); yield* runGit(repoDir, ["add", "local.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); @@ -2545,7 +2593,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]); - fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "upstream.txt"), "upstream\n"); yield* runGit(repoDir, ["add", "upstream.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); @@ -2603,7 +2651,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]); - fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "no-head-repo.txt"), "upstream\n"); yield* runGit(repoDir, ["add", "no-head-repo.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); @@ -2650,7 +2698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); - fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "worktree.txt"), "worktree\n"); yield* runGit(repoDir, ["add", "worktree.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); @@ -2678,7 +2726,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch).toBe("feature/pr-worktree"); expect(result.worktreePath).not.toBeNull(); - expect(fs.existsSync(result.worktreePath as string)).toBe(true); + expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); const worktreeBranch = (yield* runGit(result.worktreePath as string, [ "branch", "--show-current", @@ -2687,6 +2735,65 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const missingForkDir = NodePath.join(repoDir, "missing-fork.git"); + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 93, + title: "Missing fork branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/93", + baseRefName: "main", + headRefName: "feature/missing-fork-branch", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: missingForkDir, + sshUrl: missingForkDir, + }, + }, + }, + }); + + const error = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "93", + mode: "worktree", + }).pipe(Effect.flip); + + if (error._tag !== "GitPullRequestMaterializationError") { + return yield* Effect.die(error); + } + expect(error).toMatchObject({ + cwd: repoDir, + pullRequestNumber: 93, + headRepository: "octocat/codething-mvp", + headBranch: "feature/missing-fork-branch", + localBranch: "t3code/pr-93/feature/missing-fork-branch", + }); + if (!(error.cause instanceof AggregateError)) { + return yield* Effect.die(error.cause); + } + expect(error.cause.errors).toHaveLength(2); + expect(error.cause.errors).toEqual([ + expect.objectContaining({ _tag: "GitCommandError" }), + expect.objectContaining({ _tag: "GitCommandError" }), + ]); + expect(error.cause.cause).toBe(error.cause.errors[0]); + }), + ); + it.effect("launches setup only when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2695,14 +2802,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); - fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "setup.txt"), "setup\n"); yield* runGit(repoDir, ["add", "setup.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); yield* runGit(repoDir, ["checkout", "main"]); - const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -2750,7 +2857,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); - fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork.txt"), "fork\n"); yield* runGit(repoDir, ["add", "fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); @@ -2812,7 +2919,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); - fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-fork.txt"), "local fork\n"); yield* runGit(repoDir, ["add", "local-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); @@ -2865,7 +2972,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); - fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "derived-fork.txt"), "derived fork\n"); yield* runGit(repoDir, ["add", "derived-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); yield* runGit(repoDir, [ @@ -2917,14 +3024,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); - fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "existing.txt"), "existing\n"); yield* runGit(repoDir, ["add", "existing.txt"]); yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = path.join(repoDir, "..", `pr-existing-${path.basename(repoDir)}`); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-existing-${NodePath.basename(repoDir)}`, + ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); - const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -2952,8 +3063,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { threadId: asThreadId("thread-pr-existing-worktree"), }); - expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( - fs.realpathSync.native(worktreePath), + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); expect(setupCalls).toHaveLength(0); @@ -2972,7 +3083,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main.txt"), "fork main\n"); yield* runGit(repoDir, ["add", "fork-main.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); @@ -3032,7 +3143,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main-second.txt"), "fork main second\n"); yield* runGit(repoDir, ["add", "fork-main-second.txt"]); yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); @@ -3090,12 +3201,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); - fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-fork.txt"), "reused fork\n"); yield* runGit(repoDir, ["add", "reused-fork.txt"]); yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${path.basename(repoDir)}`); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-fork-${NodePath.basename(repoDir)}`, + ); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); @@ -3127,8 +3242,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { mode: "worktree", }); - expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( - fs.realpathSync.native(worktreePath), + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), ); expect( (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), @@ -3144,7 +3259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); - fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "setup-failure.txt"), "setup failure\n"); yield* runGit(repoDir, ["add", "setup-failure.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); @@ -3163,8 +3278,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, setupScriptRunner: { - runForThread: () => - Effect.fail(new ProjectSetupScriptRunnerError({ message: "terminal start failed" })), + runForThread: (input) => + Effect.fail( + new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ + threadId: input.threadId, + worktreePath: input.worktreePath, + operation: "openTerminal", + cause: new Error("terminal start failed"), + }), + ), }, }); @@ -3177,7 +3299,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch).toBe("feature/pr-setup-failure"); expect(result.worktreePath).not.toBeNull(); - expect(fs.existsSync(result.worktreePath as string)).toBe(true); + expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); }), ); @@ -3217,9 +3339,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); - fs.writeFileSync( - path.join(repoDir, ".git", "hooks", "pre-commit"), + NodeFS.writeFileSync(NodePath.join(repoDir, "hooked.txt"), "hooked\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n', { mode: 0o755 }, ); @@ -3280,9 +3402,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); - fs.writeFileSync( - path.join(repoDir, ".git", "hooks", "pre-commit"), + NodeFS.writeFileSync(NodePath.join(repoDir, "hook-failure.txt"), "broken\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', { mode: 0o755 }, ); @@ -3310,13 +3432,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.map((error) => error.message), ); - expect(errorMessage).toContain("hook: fail"); + expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); + expect(errorMessage).not.toContain("hook: fail"); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "hook_started", hookName: "pre-commit", }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: fail", + }), expect.objectContaining({ kind: "action_failed", phase: "commit", @@ -3333,7 +3460,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "pr only\n"); yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 99121182713..f1fb03e7e45 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -41,14 +41,14 @@ import { type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { GitManagerError } from "@t3tools/contracts"; -import { TextGeneration } from "../textGeneration/TextGeneration.ts"; -import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; +import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; +import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; -import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; -import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { @@ -60,34 +60,34 @@ export interface GitRunStackedActionOptions { readonly progressReporter?: GitActionProgressReporter; } -export interface GitManagerShape { - readonly status: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly localStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly remoteStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; - readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; - readonly invalidateStatus: (cwd: string) => Effect.Effect; - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect; - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; -} - -export class GitManager extends Context.Service()( - "t3/git/GitManager", -) {} +export class GitManager extends Context.Service< + GitManager, + { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + options?: GitVcsDriver.GitRemoteStatusOptions, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; + } +>()("t3/git/GitManager") {} const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -320,14 +320,6 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { }; } -function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { - return new GitManagerError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; @@ -526,26 +518,36 @@ function toPullRequestHeadRemoteInfo(pr: { }; } -export const makeGitManager = Effect.fn("makeGitManager")(function* () { - const gitCore = yield* GitVcsDriver; - const sourceControlProviders = yield* SourceControlProviderRegistry; - const textGeneration = yield* TextGeneration; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; +export const make = Effect.gen(function* () { + const gitCore = yield* GitVcsDriver.GitVcsDriver; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const textGeneration = yield* TextGeneration.TextGeneration; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); - const serverSettingsService = yield* ServerSettingsService; - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => - gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause), - ), - ); + const serverSettingsService = yield* ServerSettings.ServerSettingsService; + const randomUUIDv4 = (cwd: string) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "randomUUIDv4", + cwd, + detail: "Failed to generate Git operation identifier.", + cause, + }), + ), + ); const createProgressEmitter = ( input: { cwd: string; action: GitStackedAction }, options?: GitRunStackedActionOptions, ) => - (options?.actionId === undefined ? randomUUIDv4 : Effect.succeed(options.actionId)).pipe( + (options?.actionId === undefined + ? randomUUIDv4(input.cwd) + : Effect.succeed(options.actionId) + ).pipe( Effect.map((actionId) => { const reporter = options?.progressReporter; const emit = (event: GitActionProgressPayload) => @@ -629,9 +631,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) => configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.configurePullRequestHeadUpstream failed", { + cwd, + localBranch, + headBranch: pullRequest.headBranch, + cause: error, + }).pipe(Effect.asVoid), ), ); @@ -689,12 +694,30 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { localBranch = pullRequest.headBranch, ) => materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( - Effect.catch(() => - gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }), + Effect.catch((primaryCause) => + gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + Effect.mapError( + (fallbackCause) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause: new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), + }), + ), + ), ), ); const fileSystem = yield* FileSystem.FileSystem; @@ -716,7 +739,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, - } satisfies GitStatusDetails; + } satisfies GitVcsDriver.GitStatusDetails; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { const details = yield* gitCore .statusDetailsLocal(cwd) @@ -745,9 +768,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), ); - const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { + const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( + cwd: string, + options?: GitVcsDriver.GitRemoteStatusOptions, + ) { const details = yield* gitCore - .statusDetailsRemote(cwd) + .statusDetailsRemote(cwd, options) .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); if (details === null || !details.isRepo) { return null; @@ -778,7 +804,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { pr, } satisfies VcsStatusRemoteResult; }); - const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { + const remoteStatusResultCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus(cwd), { capacity: STATUS_RESULT_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); @@ -1089,6 +1115,27 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return "main"; }); + const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* ( + cwd: string, + baseBranch: string, + ) { + const remoteName = yield* gitCore + .resolvePrimaryRemoteName(cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (!remoteName) return baseBranch; + + return yield* gitCore + .resolveRemoteTrackingCommit({ + cwd, + refName: baseBranch, + fallbackRemoteName: remoteName, + }) + .pipe( + Effect.map((resolved) => resolved.commitSha), + Effect.orElseSucceed(() => baseBranch), + ); + }); + const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( function* (input: { cwd: string; @@ -1260,16 +1307,18 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { - return yield* gitManagerError( - "runPrStep", - "Cannot create a pull request from detached HEAD.", - ); + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); } if (!details.hasUpstream) { - return yield* gitManagerError( - "runPrStep", - "Current branch has not been pushed. Push before creating a PR.", - ); + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Current branch has not been pushed. Push before creating a PR.", + }); } const headContext = yield* resolveBranchHeadContext(cwd, { @@ -1295,7 +1344,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { phase: "pr", label: `Generating ${terms.shortLabel} content...`, }); - const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); + const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); const generated = yield* textGeneration.generatePrContent({ cwd, @@ -1307,14 +1357,21 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { modelSelection, }); - const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${yield* randomUUIDv4}.md`); - yield* fileSystem - .writeFileString(bodyFile, generated.body) - .pipe( - Effect.mapError((cause) => - gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), - ), - ); + const bodyFile = path.join( + tempDir, + `t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`, + ); + yield* fileSystem.writeFileString(bodyFile, generated.body).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Failed to write pull request body temp file.", + cause, + }), + ), + ); yield* emit({ kind: "phase_started", phase: "pr", @@ -1350,53 +1407,58 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); - const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { - const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - return yield* Cache.get(localStatusResultCache, cacheKey); - }); - const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( + const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( function* (input) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(localStatusResultCache, cacheKey); + }, + ); + const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( + function* (input, options) { + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + if (options?.refreshUpstream === false) { + return yield* readRemoteStatus(cacheKey, options); + } return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); - const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { + const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) { const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], { concurrency: "unbounded", }); return mergeGitStatusParts(local, remote); }); - const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( + const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); }); - const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( + const invalidateRemoteStatus: GitManager["Service"]["invalidateRemoteStatus"] = Effect.fn( "invalidateRemoteStatus", )(function* (cwd) { yield* invalidateRemoteStatusResultCache(cwd); }); - const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( + const invalidateStatus: GitManager["Service"]["invalidateStatus"] = Effect.fn("invalidateStatus")( function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); }, ); - const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( - function* (input) { - const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) - .getChangeRequest({ - cwd: input.cwd, - reference: normalizePullRequestReference(input.reference), - }) - .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( + "resolvePullRequest", + )(function* (input) { + const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) + .getChangeRequest({ + cwd: input.cwd, + reference: normalizePullRequestReference(input.reference), + }) + .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); - return { pullRequest }; - }, - ); + return { pullRequest }; + }); - const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( + const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { const maybeRunSetupScript = (worktreePath: string) => { @@ -1411,9 +1473,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }) .pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.preparePullRequestThread setup script failed", { + threadId: input.threadId, + worktreePath, + cause: error, + }).pipe(Effect.asVoid), ), ); }; @@ -1511,10 +1575,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; } if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); } yield* materializePullRequestHeadBranch( @@ -1539,10 +1605,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; } if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); } const worktree = yield* gitCore.createWorktree({ @@ -1577,10 +1645,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { modelSelection, }); if (!suggestion) { - return yield* gitManagerError( - "runFeatureBranchStep", - "Cannot create a feature branch because there are no changes to commit.", - ); + return yield* new GitManagerError({ + operation: "runFeatureBranchStep", + cwd, + detail: "Cannot create a feature branch because there are no changes to commit.", + }); } const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); @@ -1597,7 +1666,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); - const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( + const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")( function* (input, options) { const progress = yield* createProgressEmitter(input, options); const currentPhase = yield* Ref.make>(Option.none()); @@ -1617,16 +1686,18 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; if (input.featureBranch && !wantsCommit) { - return yield* gitManagerError( - "runStackedAction", - "Feature-branch checkout is only supported for commit actions.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Feature-branch checkout is only supported for commit actions.", + }); } if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit local changes before creating a PR.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Commit local changes before creating a PR.", + }); } const phases: GitActionProgressPhase[] = [ @@ -1642,13 +1713,18 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); if (!input.featureBranch && wantsPush && !initialStatus.branch) { - return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot push from detached HEAD.", + }); } if (!input.featureBranch && wantsPr && !initialStatus.branch) { - return yield* gitManagerError( - "runStackedAction", - "Cannot create a pull request from detached HEAD.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); } let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; @@ -1657,8 +1733,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const modelSelection = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.textGenerationModelSelection), - Effect.mapError((cause) => - gitManagerError("runStackedAction", "Failed to get server settings.", cause), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Failed to get server settings.", + cause, + }), ), ); @@ -1776,7 +1858,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }, ); - return { + return GitManager.of({ localStatus, remoteStatus, status, @@ -1786,7 +1868,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { resolvePullRequest, preparePullRequestThread, runStackedAction, - } satisfies GitManagerShape; + }); }); -export const layer = Layer.effect(GitManager, makeGitManager()); +export const layer = Layer.effect(GitManager, make); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 9a34680496f..2ea14b951fe 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,13 +1,17 @@ -import { assert, describe, it, vi } from "@effect/vitest"; +import { assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; + import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistryShape["detect"] }) { +function makeLayer(input: { + readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; +}) { return GitWorkflowService.layer.pipe( Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ @@ -130,4 +134,59 @@ describe("GitWorkflowService", () => { ), ), ); + + it.effect("structures workflow detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "GitWorkflowService.status", + cwd: "/repo", + detail: "Failed to detect a VCS repository for this Git workflow.", + }); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }); + + it.effect("structures command detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream command detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitCommandError", + operation: "GitWorkflowService.listRefs", + command: "vcs-route", + cwd: "/repo", + detail: "Failed to detect a VCS repository for this Git command.", + }); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 74064450fcb..100b9beadba 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -28,71 +28,72 @@ import { type VcsStatusResult, } from "@t3tools/contracts"; -import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; -import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; -import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; - -export interface GitWorkflowServiceShape { - readonly status: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly localStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly remoteStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; - readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; - readonly invalidateStatus: (cwd: string) => Effect.Effect; - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect; - readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; - readonly createWorktree: ( - input: VcsCreateWorktreeInput, - ) => Effect.Effect; - readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; - readonly createRef: ( - input: VcsCreateRefInput, - ) => Effect.Effect; - readonly switchRef: ( - input: VcsSwitchRefInput, - ) => Effect.Effect; - readonly renameBranch: (input: { - readonly cwd: string; - readonly oldBranch: string; - readonly newBranch: string; - }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; -} +import * as GitManager from "./GitManager.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, - GitWorkflowServiceShape + { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + options?: GitVcsDriver.GitRemoteStatusOptions, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitManager.GitRunStackedActionOptions, + ) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly listRefs: ( + input: VcsListRefsInput, + ) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly fetchRemote: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; + readonly resolveRemoteTrackingCommit: (input: { + readonly cwd: string; + readonly refName: string; + readonly fallbackRemoteName: string; + }) => Effect.Effect< + { readonly commitSha: string; readonly remoteRefName: string }, + GitCommandError + >; + readonly removeWorktree: ( + input: VcsRemoveWorktreeInput, + ) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; + } >()("t3/git/GitWorkflowService") {} -const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => - new GitManagerError({ - operation, - detail: `${detail} (${cwd})`, - }); - -const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => - new GitCommandError({ - operation, - command: "vcs-route", - cwd, - detail, - }); - function nonRepositoryLocalStatus(): VcsStatusLocalResult { return { isRepo: false, @@ -129,32 +130,32 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } -export const make = Effect.fn("makeGitWorkflowService")(function* () { - const registry = yield* VcsDriverRegistry; - const git = yield* GitVcsDriver; - const gitManager = yield* GitManager; +export const make = Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const git = yield* GitVcsDriver.GitVcsDriver; + const gitManager = yield* GitManager.GitManager; const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, cwd: string, ) { - const handle = yield* registry - .resolve({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ operation, cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to resolve the VCS driver for this Git workflow.", + cause, + }), + ), + ); if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( + return yield* new GitManagerError({ operation, cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, + }); } }); @@ -162,48 +163,50 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { operation: string, cwd: string, ) { - const handle = yield* registry - .resolve({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitCommand( + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ operation, + command: "vcs-route", cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to resolve the VCS driver for this Git command.", + cause, + }), + ), + ); if (handle.kind !== "git") { - return yield* unsupportedGitCommand( + return yield* new GitCommandError({ operation, + command: "vcs-route", cwd, - `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + }); } }); const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ operation, cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, + }), + ), + ); if (!handle) { return false; } if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( + return yield* new GitManagerError({ operation, cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, + }); } return true; }, @@ -212,26 +215,28 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { const detectGitRepositoryForCommand = Effect.fn( "GitWorkflowService.detectGitRepositoryForCommand", )(function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitCommand( + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ operation, + command: "vcs-route", cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to detect a VCS repository for this Git command.", + cause, + }), + ), + ); if (!handle) { return false; } if (handle.kind !== "git") { - return yield* unsupportedGitCommand( + return yield* new GitCommandError({ operation, + command: "vcs-route", cwd, - `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + }); } return true; }); @@ -259,10 +264,10 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { : Effect.succeed(nonRepositoryLocalStatus()), ), ), - remoteStatus: (input) => + remoteStatus: (input, options) => detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( Effect.flatMap((isGitRepository) => - isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null), + isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null), ), ), invalidateLocalStatus: gitManager.invalidateLocalStatus, @@ -294,6 +299,14 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), ), + fetchRemote: (input) => + ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( + Effect.andThen(git.fetchRemote(input)), + ), + resolveRemoteTrackingCommit: (input) => + ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( + Effect.andThen(git.resolveRemoteTrackingCommit(input)), + ), removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), @@ -313,4 +326,4 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { }); }); -export const layer = Layer.effect(GitWorkflowService, make()); +export const layer = Layer.effect(GitWorkflowService, make); diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index b414daaa0a4..e4a703f4454 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; export function isGitRepository(cwd: string): boolean { - return existsSync(join(cwd, ".git")); + return NodeFS.existsSync(NodePath.join(cwd, ".git")); } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index f1d274813de..6c3f5fb5946 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -25,21 +25,22 @@ import { import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; -import { resolveStaticDir, ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, FALLBACK_PROJECT_FAVICON_SVG, resolveAsset, } from "./assets/AssetAccess.ts"; -import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; +import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, failEnvironmentScopeRequired, failEnvironmentAuthInvalid, failEnvironmentInternal, } from "./auth/http.ts"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; @@ -48,7 +49,7 @@ const INDEX_HTML_FILE_NAME = "index.html"; export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const devOrigin = config.devUrl?.origin; return HttpRouter.cors({ ...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}), @@ -90,10 +91,12 @@ const authenticateRawRouteWithScope = ( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); if (!session.scopes.includes(scope)) { return yield* failEnvironmentScopeRequired(scope); @@ -104,13 +107,13 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "metadata", Effect.fnUntraced(function* (handlers) { - const serverEnvironment = yield* ServerEnvironment; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; return handlers.handle( "descriptor", Effect.fn("environment.metadata.descriptor")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); return yield* serverEnvironment.getDescriptor; - }), + }, traceRelayRequest), ); }), ); @@ -126,9 +129,9 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( Effect.gen(function* () { yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope); const request = yield* HttpServerRequest.HttpServerRequest; - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; - const browserTraceCollector = yield* BrowserTraceCollector; + const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; const bodyJson = cast(yield* request.json); @@ -223,10 +226,11 @@ export const assetRouteLayer = HttpRouter.add( export const staticAndDevRouteLayer = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); + const staticDir = + config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined); const staticRoot = staticDir ? path.resolve(staticDir) : null; const indexHtml = staticRoot === null diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 1bfd042d078..a51ad20afbe 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -9,28 +9,21 @@ import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { ServerConfig } from "./config.ts"; - -import { - DEFAULT_KEYBINDINGS, - Keybindings, - KeybindingsLive, - ResolvedKeybindingFromConfig, - compileResolvedKeybindingRule, - compileResolvedKeybindingsConfig, - parseKeybindingShortcut, -} from "./keybindings.ts"; +import * as ServerConfig from "./config.ts"; +import * as Keybindings from "./keybindings.ts"; import { KeybindingsConfigError } from "@t3tools/contracts"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const encodeKeybindingsConfigJson = Schema.encodeEffect(KeybindingsConfigJson); const decodeKeybindingsConfigJson = Schema.decodeUnknownEffect(KeybindingsConfigJson); -const encodeResolvedKeybindingFromConfig = Schema.encodeEffect(ResolvedKeybindingFromConfig); +const encodeResolvedKeybindingFromConfig = Schema.encodeEffect( + Keybindings.ResolvedKeybindingFromConfig, +); const decodeResolvedKeybindingFromConfigExit = Schema.decodeUnknownExit( - ResolvedKeybindingFromConfig, + Keybindings.ResolvedKeybindingFromConfig, ); const makeKeybindingsLayer = () => { - return KeybindingsLive.pipe( + return Keybindings.layer.pipe( Layer.provideMerge( Layer.fresh( ServerConfig.layerTest(process.cwd(), { @@ -66,7 +59,7 @@ const readKeybindingsConfig = (configPath: string) => it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("parses shortcuts including plus key", () => Effect.sync(() => { - assert.deepEqual(parseKeybindingShortcut("mod+j"), { + assert.deepEqual(Keybindings.parseKeybindingShortcut("mod+j"), { key: "j", metaKey: false, ctrlKey: false, @@ -74,7 +67,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { altKey: false, modKey: true, }); - assert.deepEqual(parseKeybindingShortcut("mod++"), { + assert.deepEqual(Keybindings.parseKeybindingShortcut("mod++"), { key: "+", metaKey: false, ctrlKey: false, @@ -87,7 +80,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("compiles valid rule with parsed when AST", () => Effect.sync(() => { - const compiled = compileResolvedKeybindingRule({ + const compiled = Keybindings.compileResolvedKeybindingRule({ key: "mod+d", command: "terminal.split", when: "terminalOpen && !terminalFocus", @@ -137,14 +130,14 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("rejects invalid rules", () => Effect.sync(() => { assert.isNull( - compileResolvedKeybindingRule({ + Keybindings.compileResolvedKeybindingRule({ key: "mod+shift+d+o", command: "terminal.new", }), ); assert.isNull( - compileResolvedKeybindingRule({ + Keybindings.compileResolvedKeybindingRule({ key: "mod+d", command: "terminal.split", when: "terminalFocus && (", @@ -152,7 +145,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); assert.isNull( - compileResolvedKeybindingRule({ + Keybindings.compileResolvedKeybindingRule({ key: "mod+d", command: "terminal.split", when: `${"!".repeat(300)}terminalFocus`, @@ -181,23 +174,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("bootstraps default keybindings when config file is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; assert.isFalse(yield* fs.exists(keybindingsConfigPath)); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* keybindings.syncDefaultKeybindingsOnStartup; }); const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); - assert.deepEqual(persisted, DEFAULT_KEYBINDINGS); + assert.deepEqual(persisted, Keybindings.DEFAULT_KEYBINDINGS); }).pipe(Effect.provide(makeKeybindingsLayer())), ); it.effect("ships configurable thread navigation defaults", () => Effect.sync(() => { const defaultsByCommand = new Map( - DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), + Keybindings.DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), ); assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); @@ -205,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); @@ -215,17 +209,17 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); const configState = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.loadConfigState; }); assert.deepEqual( configState.keybindings, - compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS), + Keybindings.compileResolvedKeybindingsConfig(Keybindings.DEFAULT_KEYBINDINGS), ); assert.deepEqual(configState.issues, [ { @@ -240,7 +234,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("ignores invalid entries in runtime and reports them as issues", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -252,7 +246,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); const configState = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.loadConfigState; }); @@ -279,14 +273,14 @@ it.layer(NodeServices.layer)("keybindings", (it) => { "upserts missing default keybindings on startup without overriding existing command rules", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+shift+t", command: "terminal.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* keybindings.syncDefaultKeybindingsOnStartup; }); @@ -300,7 +294,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"), ); - for (const defaultRule of DEFAULT_KEYBINDINGS) { + for (const defaultRule of Keybindings.DEFAULT_KEYBINDINGS) { assert.isTrue(byCommand.has(defaultRule.command), `expected ${defaultRule.command}`); } assert.isTrue(byCommand.has("script.run-tests.run")); @@ -314,13 +308,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); return Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "script.custom-action.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* keybindings.syncDefaultKeybindingsOnStartup; }); @@ -345,13 +339,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("upserts custom keybindings to configured path", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, ]); const resolved = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -371,12 +365,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("appends additional custom keybindings for the same command", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+r", command: "script.run-tests.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -394,13 +388,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("replaces only the targeted custom keybinding", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+r", command: "script.run-tests.run" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+alt+r", command: "script.run-tests.run", @@ -419,13 +413,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("removes only the targeted custom keybinding", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+r", command: "script.run-tests.run" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.removeKeybindingRule({ key: "mod+r", command: "script.run-tests.run", @@ -441,11 +435,11 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("refuses to overwrite malformed keybindings config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); const result = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -461,14 +455,14 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("reports non-array config parse errors without duplicate prefix", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, '{"key":"mod+j","command":"terminal.toggle"}', ); const firstResult = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -477,7 +471,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assertFailure(firstResult, "expected JSON array"); const secondResult = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -490,7 +484,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("fails when config directory is not writable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; const { dirname } = yield* Path.Path; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, @@ -498,7 +492,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); const result = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -516,13 +510,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("caches loaded resolved config across repeated reads", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, ]); const [first, second] = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; const firstLoad = (yield* keybindings.loadConfigState).keybindings; const secondLoad = (yield* keybindings.loadConfigState).keybindings; return [firstLoad, secondLoad] as const; @@ -535,13 +529,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("updates cached resolved config after upsert", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, ]); const loadedAfterUpsert = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* keybindings.loadConfigState; yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", @@ -557,7 +551,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("serializes concurrent upserts to avoid lost updates", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, []); const commands = Array.from( @@ -565,7 +559,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { (_, index): KeybindingCommand => `script.concurrent-${index}.run`, ); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* Effect.all( commands.map((command, index) => keybindings.upsertKeybindingRule({ diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 80b522eee71..5ddae4943f8 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -41,7 +41,7 @@ import * as Context from "effect/Context"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { @@ -225,74 +225,70 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve return merged.slice(-MAX_KEYBINDINGS_COUNT); } -/** - * KeybindingsShape - Service API for keybinding configuration operations. - */ -export interface KeybindingsShape { - /** - * Start the keybindings runtime and attach file watching. - * - * Safe to call multiple times. The first successful call establishes the - * runtime; later calls await the same startup. - */ - readonly start: Effect.Effect; - - /** - * Await keybindings runtime readiness. - * - * Readiness means the config directory exists, the watcher is attached, the - * startup sync has completed, and the current snapshot has been loaded. - */ - readonly ready: Effect.Effect; - - /** - * Ensure the on-disk keybindings file exists and includes all default - * commands so newly-added defaults are backfilled on startup. - */ - readonly syncDefaultKeybindingsOnStartup: Effect.Effect; - - /** - * Load runtime keybindings state along with non-fatal configuration issues. - */ - readonly loadConfigState: Effect.Effect; - - /** - * Read the latest keybindings snapshot from cache/disk. - */ - readonly getSnapshot: Effect.Effect; - - /** - * Stream of keybindings config change events. - */ - readonly streamChanges: Stream.Stream; - - /** - * Upsert a keybinding rule and persist the resulting configuration. - * - * Writes config atomically and enforces the max rule count by truncating - * oldest entries when needed. - */ - readonly upsertKeybindingRule: ( - input: ServerUpsertKeybindingInput, - ) => Effect.Effect; - - /** - * Remove a single persisted keybinding rule by exact key/command/when match. - */ - readonly removeKeybindingRule: ( - input: ServerRemoveKeybindingInput, - ) => Effect.Effect; -} - /** * Keybindings - Service tag for keybinding configuration operations. */ -export class Keybindings extends Context.Service()( - "t3/keybindings", -) {} +export class Keybindings extends Context.Service< + Keybindings, + { + /** + * Start the keybindings runtime and attach file watching. + * + * Safe to call multiple times. The first successful call establishes the + * runtime; later calls await the same startup. + */ + readonly start: Effect.Effect; + + /** + * Await keybindings runtime readiness. + * + * Readiness means the config directory exists, the watcher is attached, the + * startup sync has completed, and the current snapshot has been loaded. + */ + readonly ready: Effect.Effect; + + /** + * Ensure the on-disk keybindings file exists and includes all default + * commands so newly-added defaults are backfilled on startup. + */ + readonly syncDefaultKeybindingsOnStartup: Effect.Effect; + + /** + * Load runtime keybindings state along with non-fatal configuration issues. + */ + readonly loadConfigState: Effect.Effect; + + /** + * Read the latest keybindings snapshot from cache/disk. + */ + readonly getSnapshot: Effect.Effect; + + /** + * Stream of keybindings config change events. + */ + readonly streamChanges: Stream.Stream; + + /** + * Upsert a keybinding rule and persist the resulting configuration. + * + * Writes config atomically and enforces the max rule count by truncating + * oldest entries when needed. + */ + readonly upsertKeybindingRule: ( + input: ServerUpsertKeybindingInput, + ) => Effect.Effect; + + /** + * Remove a single persisted keybinding rule by exact key/command/when match. + */ + readonly removeKeybindingRule: ( + input: ServerRemoveKeybindingInput, + ) => Effect.Effect; + } +>()("t3/keybindings") {} -const makeKeybindings = Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; +const make = Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const upsertSemaphore = yield* Semaphore.make(1); @@ -700,7 +696,7 @@ const makeKeybindings = Effect.gen(function* () { return nextResolved; }), ), - } satisfies KeybindingsShape; + } satisfies Keybindings["Service"]; }); -export const KeybindingsLive = Layer.effect(Keybindings, makeKeybindings); +export const layer = Layer.effect(Keybindings, make); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 25509dc593f..f550396c660 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -49,6 +49,62 @@ it("normalizes empty successful notification responses to accepted", () => { expect(resultResponse.status).toBe(200); }); +it.effect("returns bounded structural preview snapshot failures", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const requests = yield* broker.connect({ + clientId: "mcp-failure-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message: "sensitive renderer failure", + detail: { consoleOutput: "sensitive browser output" }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "mcp-failure-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + const snapshot = yield* server + .callTool({ name: "preview_snapshot", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(snapshot.isError).toBe(true); + expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); + expect(snapshot.structuredContent).toEqual({ + error: { + _tag: "PreviewAutomationExecutionError", + operation: "snapshot", + failureCount: 1, + }, + }); + }), + ).pipe(Effect.provide(TestLayer)), +); + it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { @@ -107,7 +163,15 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.gen(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const requests = yield* broker.connect("mcp-test-client"); + const requests = yield* broker.connect({ + clientId: "mcp-test-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6cde2017a9e..e95662a30f8 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -88,6 +88,37 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; +const previewSnapshotFailure = (cause: Cause.Cause) => { + if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { + return Effect.failCause(cause).pipe(Effect.orDie); + } + const failures = cause.reasons.filter(Cause.isFailReason); + const firstFailure = failures[0]?.error; + const errorTag = + typeof firstFailure === "object" && + firstFailure !== null && + "_tag" in firstFailure && + typeof firstFailure._tag === "string" + ? firstFailure._tag + : "PreviewSnapshotError"; + const result = new McpSchema.CallToolResult({ + isError: true, + structuredContent: { + error: { + _tag: errorTag, + operation: "snapshot", + failureCount: failures.length, + }, + }, + content: [{ type: "text", text: "Preview snapshot failed." }], + }); + return Effect.logWarning("preview snapshot failed", { + operation: "snapshot", + errorTag, + failureCount: failures.length, + }).pipe(Effect.as(result)); +}; + const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; @@ -122,12 +153,8 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.matchCause({ - onFailure: (cause) => - new McpSchema.CallToolResult({ - isError: true, - content: [{ type: "text", text: Cause.pretty(cause) }], - }), + Effect.matchCauseEffect({ + onFailure: previewSnapshotFailure, onSuccess: ({ encodedResult }) => { const snapshot = encodedResult as { readonly screenshot: { @@ -147,18 +174,20 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot height: screenshot.height, }, }; - return new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - { - type: "image", - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ], - }); + return Effect.succeed( + new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + { + type: "image", + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ], + }), + ); }, }), ); diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts new file mode 100644 index 00000000000..39c68689047 --- /dev/null +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PreviewAutomationUnavailableError, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as McpInvocationContext from "./McpInvocationContext.ts"; + +it.effect("reports the scoped credential context when preview capability is unavailable", () => { + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(), + issuedAt: 1, + expiresAt: 2, + }; + + return Effect.gen(function* () { + const error = yield* McpInvocationContext.requireMcpCapability("preview").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.flip, + ); + + expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); + expect(error).toMatchObject({ + capability: "preview", + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }); + expect(error.message).toBe("MCP credential does not grant the preview capability."); + }); +}); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 0d3f84df42c..b13bf2d312e 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,5 +1,9 @@ -import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; +import { + type EnvironmentId, + PreviewAutomationUnavailableError, + type ProviderInstanceId, + type ThreadId, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -26,7 +30,11 @@ export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* const invocation = yield* McpInvocationContext; if (!invocation.capabilities.has(capability)) { return yield* new PreviewAutomationUnavailableError({ - message: `MCP credential does not grant the ${capability} capability.`, + capability, + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, }); } return invocation; diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index d6540d567af..a91d98febd8 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -4,20 +4,22 @@ import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; const environmentId = EnvironmentId.make("environment-1"); -const fakeHttpServer = HttpServer.HttpServer.of({ - address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 }, - serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], -}); -const fakeEnvironment = ServerEnvironment.of({ +const makeFakeHttpServer = (hostname: string, port = 43123) => + HttpServer.HttpServer.of({ + address: { _tag: "TcpAddress", hostname, port }, + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], + }); +const fakeHttpServer = makeFakeHttpServer("127.0.0.1"); +const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.die("unused"), }); -const makeRegistry = (now: () => number) => +const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => McpSessionRegistry.__testing .make({ now, @@ -25,8 +27,8 @@ const makeRegistry = (now: () => number) => maximumLifetimeMs: 1_000, }) .pipe( - Effect.provideService(HttpServer.HttpServer, fakeHttpServer), - Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(HttpServer.HttpServer, httpServer), + Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment), Effect.provide(NodeServices.layer), ); @@ -53,6 +55,26 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t }), ); +it.effect("builds MCP endpoints from the bound server host", () => + Effect.gen(function* () { + const cases = [ + ["100.64.0.40", "http://100.64.0.40:43123/mcp"], + ["0.0.0.0", "http://127.0.0.1:43123/mcp"], + ["localhost", "http://localhost:43123/mcp"], + ["127.0.0.1", "http://127.0.0.1:43123/mcp"], + ] as const; + + for (const [hostname, expectedEndpoint] of cases) { + const registry = yield* makeRegistry(() => 1_000, makeFakeHttpServer(hostname)); + const issued = yield* registry.issue({ + threadId: ThreadId.make(`thread-${hostname}`), + providerInstanceId: ProviderInstanceId.make("codex"), + }); + expect(issued.config.endpoint).toBe(expectedEndpoint); + } + }), +); + it.effect("expires credentials after inactivity", () => Effect.gen(function* () { let timestamp = 1_000; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 1ee7d278c62..67c4f2f0ff0 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { HttpServer } from "effect/unstable/http"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpProviderSession from "./McpProviderSession.ts"; @@ -60,11 +60,22 @@ const bytesToHex = (bytes: Uint8Array): string => const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); +const getHttpMcpEndpointHost = (hostname: string): string => { + const normalized = hostname.toLowerCase(); + const endpointHostname = + normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]" + ? "127.0.0.1" + : hostname; + return endpointHostname.includes(":") && !endpointHostname.startsWith("[") + ? `[${endpointHostname}]` + : endpointHostname; +}; + const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( options: McpSessionRegistryOptions = {}, ) { const crypto = yield* Crypto.Crypto; - const environment = yield* ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironment; const environmentId = yield* environment.getEnvironmentId; const httpServer = yield* HttpServer.HttpServer; const state = yield* SynchronizedRef.make({ records: new Map() }); @@ -73,7 +84,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; const endpoint = httpServer.address._tag === "TcpAddress" - ? `http://127.0.0.1:${httpServer.address.port}/mcp` + ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` : "http://127.0.0.1/mcp"; const hashToken = (token: string) => @@ -180,11 +191,7 @@ const make = Effect.acquireRelease( }), ); -export const layer: Layer.Layer< - McpSessionRegistry, - never, - Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer -> = Layer.effect(McpSessionRegistry, make); +export const layer = Layer.effect(McpSessionRegistry, make); export const issueActiveMcpCredential = ( request: McpCredentialRequest, diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 353353aaef2..9f7ef2113d7 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1,11 +1,16 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, + PreviewAutomationClientDisconnectedError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationMalformedResponseError, PreviewAutomationNoFocusedOwnerError, ProviderInstanceId, ThreadId, + type PreviewAutomationOwner, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -20,11 +25,22 @@ const scope = { expiresAt: 2, }; -it.effect("routes a request to the focused owner and correlates its response", () => +const makeOwner = (overrides: Partial = {}): PreviewAutomationOwner => ({ + clientId: "client-1", + environmentId: scope.environmentId, + threadId: scope.threadId, + tabId: null, + visible: false, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + ...overrides, +}); + +it.effect("atomically registers a connected owner and correlates its response", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; - const requests = yield* broker.connect("client-1"); + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner()); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, @@ -33,15 +49,6 @@ it.effect("routes a request to the focused owner and correlates its response", ( }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.reportOwner({ - clientId: "client-1", - environmentId: scope.environmentId, - threadId: scope.threadId, - tabId: null, - visible: false, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", - }); const result = yield* broker.invoke<{ available: boolean }>({ scope, @@ -54,36 +61,239 @@ it.effect("routes a request to the focused owner and correlates its response", ( ), ); +it.effect("preserves bounded request and remote selector diagnostics", () => { + const locator = "role=button[name='request-secret']"; + const remoteMessage = "Unexpected token near remote-secret."; + const remoteError = { + _tag: "PreviewAutomationInvalidSelectorError", + message: remoteMessage, + detail: { selector: "role=button[name='remote-secret']" }, + } as const; + + return Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" })); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: false, + error: remoteError, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ + scope, + operation: "click", + input: { locator }, + timeoutMs: 1_234, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationInvalidSelectorError); + expect(error).toMatchObject({ + operation: "click", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + tabId: "tab-1", + timeoutMs: 1_234, + selectorKind: "locator", + selectorLength: locator.length, + remoteTag: "PreviewAutomationInvalidSelectorError", + remoteMessageLength: remoteMessage.length, + remoteDetailKind: "object", + }); + expect(error.message).toBe( + `Preview automation click received an invalid locator (${locator.length} characters).`, + ); + expect(error.message).not.toContain("secret"); + expect(error.cause).toBe(remoteError); + expect("selector" in error).toBe(false); + expect("remoteMessage" in error).toBe(false); + expect("remoteDetail" in error).toBe(false); + }), + ); +}); + +it.effect("distinguishes malformed remote failures", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner()); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: false }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ scope, operation: "status", input: {}, timeoutMs: 2_000 }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationMalformedResponseError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 2_000, + }); + }), + ), +); + it.effect("rejects calls when no focused owner exists", () => Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; + const broker = yield* PreviewAutomationBroker.make; const error = yield* broker .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + }); }), ); it.effect("routes interactive commands to a hidden durable browser host", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; - const requests = yield* broker.connect("client-hidden"); + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect( + makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }), + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: true }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + }), + ), +); + +it.effect("lets the browser host resolve an active tab that has not been reported yet", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner({ tabId: null })); + let routedTabId: string | undefined; + yield* Stream.runForEach(requests, (request) => { + routedTabId = request.tabId; + return broker.respond({ requestId: request.requestId, ok: true }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + + expect(routedTabId).toBeUndefined(); + }), + ), +); + +it.effect("preserves current owner metadata when its request stream reconnects", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const firstRequests = yield* broker.connect(makeOwner()); + yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); + yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true })); + + const reconnectedRequests = yield* broker.connect(makeOwner()); + let routedTabId: string | undefined; + yield* Stream.runForEach(reconnectedRequests, (request) => { + routedTabId = request.tabId; + return broker.respond({ requestId: request.requestId, ok: true }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + + expect(routedTabId).toBe("tab-current"); + }), + ), +); + +it.effect("ignores stale owner cleanup after the client moves to another thread", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner()); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, ok: true }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.reportOwner({ - clientId: "client-hidden", + + yield* broker.clearOwner({ + clientId: "client-1", + environmentId: scope.environmentId, + threadId: ThreadId.make("thread-stale"), + }); + + yield* broker.invoke({ scope, operation: "status", input: {} }); + }), + ), +); + +it.effect("fails requests assigned to a browser stream when that stream reconnects", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const _requests = yield* broker.connect(makeOwner()); + const pending = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.yieldNow; + + const _replacementRequests = yield* broker.connect(makeOwner()); + + const error = yield* Fiber.join(pending); + expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); + expect(error).toMatchObject({ + operation: "status", environmentId: scope.environmentId, threadId: scope.threadId, - tabId: "tab-hidden", - visible: false, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 15_000, }); + }), + ), +); - yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); +it.effect("falls back to an older connected owner when a newer report is not connected", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" })); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: true, result: "connected" }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner( + makeOwner({ + clientId: "client-report-only", + focusedAt: "2026-06-11T00:00:01.000Z", + }), + ); + + const result = yield* broker.invoke({ scope, operation: "status", input: {} }); + + expect(result).toBe("connected"); }), ), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index e0a7b0c9285..a2bdb95f061 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -1,16 +1,21 @@ import { + PreviewAutomationClientDisconnectedError, PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, + PreviewAutomationHostNotConnectedError, PreviewAutomationInvalidSelectorError, + PreviewAutomationMalformedResponseError, PreviewAutomationNoFocusedOwnerError, + PreviewAutomationRemoteUnavailableError, + PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, PreviewAutomationTimeoutError, - PreviewAutomationUnavailableError, PreviewAutomationUnsupportedClientError, type PreviewAutomationError, type PreviewAutomationOperation, type PreviewAutomationOwner, + type PreviewAutomationOwnerIdentity, type PreviewAutomationRequest, type PreviewAutomationResponse, type PreviewTabId, @@ -34,37 +39,48 @@ export interface PreviewAutomationInvokeInput { readonly timeoutMs?: number; } -export interface PreviewAutomationBrokerShape { - readonly connect: (clientId: string) => Effect.Effect>; - readonly reportOwner: ( - owner: PreviewAutomationOwner, - ) => Effect.Effect; - readonly clearOwner: (clientId: string) => Effect.Effect; - readonly respond: ( - response: PreviewAutomationResponse, - ) => Effect.Effect; - readonly invoke: ( - request: PreviewAutomationInvokeInput, - ) => Effect.Effect; -} - export class PreviewAutomationBroker extends Context.Service< PreviewAutomationBroker, - PreviewAutomationBrokerShape + { + readonly connect: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect>; + readonly reportOwner: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect; + readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect; + readonly respond: ( + response: PreviewAutomationResponse, + ) => Effect.Effect; + readonly invoke: ( + request: PreviewAutomationInvokeInput, + ) => Effect.Effect; + } >()("t3/mcp/PreviewAutomationBroker") {} interface ClientConnection { readonly clientId: string; - readonly queue: Queue.Queue< - Parameters[0] extends never - ? never - : import("@t3tools/contracts").PreviewAutomationRequest - >; + readonly queue: Queue.Queue; } interface PendingRequest { - readonly clientId: string; + readonly queue: ClientConnection["queue"]; readonly deferred: Deferred.Deferred; + readonly context: PreviewAutomationRequestErrorContext; +} + +interface PreviewAutomationRequestErrorContext { + readonly operation: PreviewAutomationOperation; + readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; + readonly threadId: McpInvocationContext.McpInvocationScope["threadId"]; + readonly providerSessionId: string; + readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; + readonly clientId: string; + readonly requestId: string; + readonly tabId?: PreviewTabId; + readonly timeoutMs: number; + readonly selectorKind?: "locator" | "selector"; + readonly selectorLength?: number; } interface BrokerState { @@ -74,53 +90,109 @@ interface BrokerState { readonly requestSequence: number; } -const makeResponseError = ( +const selectorDiagnosticsFromInput = ( + input: unknown, +): Pick => { + if (typeof input !== "object" || input === null) return {}; + if ("locator" in input && typeof input.locator === "string") { + return { selectorKind: "locator", selectorLength: input.locator.length }; + } + if ("selector" in input && typeof input.selector === "string") { + return { selectorKind: "selector", selectorLength: input.selector.length }; + } + return {}; +}; + +type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; + +function remoteDetailKind(detail: unknown): RemoteDetailKind { + if (detail === null) return "null"; + if (Array.isArray(detail)) return "array"; + switch (typeof detail) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + default: + return "object"; + } +} + +const classifyResponseError = ( + context: PreviewAutomationRequestErrorContext, error: NonNullable, ): PreviewAutomationError => { + const remoteDiagnostics = { + remoteTag: error._tag, + remoteMessageLength: error.message.length, + ...(error.detail === undefined ? {} : { remoteDetailKind: remoteDetailKind(error.detail) }), + cause: error, + }; switch (error._tag) { case "PreviewAutomationNoFocusedOwnerError": - return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); + return new PreviewAutomationNoFocusedOwnerError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationUnsupportedClientError": - return new PreviewAutomationUnsupportedClientError({ message: error.message }); + return new PreviewAutomationUnsupportedClientError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationTabNotFoundError": - return new PreviewAutomationTabNotFoundError({ message: error.message }); + return new PreviewAutomationTabNotFoundError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationTimeoutError": - return new PreviewAutomationTimeoutError({ message: error.message }); + return new PreviewAutomationTimeoutError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationControlInterruptedError": - return new PreviewAutomationControlInterruptedError({ message: error.message }); + return new PreviewAutomationControlInterruptedError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationInvalidSelectorError": { - const detail = - typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; return new PreviewAutomationInvalidSelectorError({ - message: error.message, - selector: - detail && "selector" in detail && typeof detail.selector === "string" - ? detail.selector - : "", + ...context, + ...remoteDiagnostics, }); } case "PreviewAutomationResultTooLargeError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + const maximumBytes = + detail && + "maximumBytes" in detail && + typeof detail.maximumBytes === "number" && + Number.isInteger(detail.maximumBytes) && + detail.maximumBytes > 0 + ? detail.maximumBytes + : undefined; return new PreviewAutomationResultTooLargeError({ - message: error.message, - maximumBytes: - detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" - ? detail.maximumBytes - : 64_000, + ...context, + ...remoteDiagnostics, + ...(maximumBytes === undefined ? {} : { maximumBytes }), }); } case "PreviewAutomationUnavailableError": - return new PreviewAutomationUnavailableError({ message: error.message }); + return new PreviewAutomationRemoteUnavailableError({ + ...context, + ...remoteDiagnostics, + }); default: return new PreviewAutomationExecutionError({ - message: error.message, - detail: error.detail, + ...context, + ...remoteDiagnostics, }); } }; -const make = Effect.gen(function* PreviewAutomationBrokerMake() { +export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const state = yield* SynchronizedRef.make({ clients: new Map(), owners: new Map(), @@ -133,17 +205,16 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { queue: ClientConnection["queue"], ) { const toFail = yield* SynchronizedRef.modify(state, (current) => { - if (current.clients.get(clientId)?.queue !== queue) { - return [[] as ReadonlyArray, current] as const; - } const clients = new Map(current.clients); const owners = new Map(current.owners); const pending = new Map(current.pending); const disconnected: PendingRequest[] = []; - clients.delete(clientId); - owners.delete(clientId); + if (current.clients.get(clientId)?.queue === queue) { + clients.delete(clientId); + owners.delete(clientId); + } for (const [requestId, entry] of pending) { - if (entry.clientId === clientId) { + if (entry.queue === queue) { pending.delete(requestId); disconnected.push(entry); } @@ -152,32 +223,37 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); yield* Effect.forEach( toFail, - ({ deferred }) => - Deferred.fail( - deferred, - new PreviewAutomationUnavailableError({ - message: "The preview automation client disconnected.", - }), - ), + ({ deferred, context }) => + Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), { discard: true }, ); yield* Queue.shutdown(queue); }); - const connect: PreviewAutomationBrokerShape["connect"] = Effect.fn( + const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( "PreviewAutomationBroker.connect", - )(function* (clientId) { + )(function* (owner) { + const clientId = owner.clientId; const queue = yield* Queue.unbounded(); const previous = yield* SynchronizedRef.modify(state, (current) => { const clients = new Map(current.clients); + const owners = new Map(current.owners); + const existingOwner = current.owners.get(clientId); clients.set(clientId, { clientId, queue }); - return [current.clients.get(clientId), { ...current, clients }] as const; + owners.set( + clientId, + existingOwner?.environmentId === owner.environmentId && + existingOwner.threadId === owner.threadId + ? { ...existingOwner, supportsAutomation: owner.supportsAutomation } + : owner, + ); + return [current.clients.get(clientId), { ...current, clients, owners }] as const; }); if (previous) yield* disconnect(clientId, previous.queue); return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); }); - const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( + const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn( "PreviewAutomationBroker.reportOwner", )(function* (owner) { yield* SynchronizedRef.update(state, (current) => { @@ -187,17 +263,25 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); }); - const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( + const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn( "PreviewAutomationBroker.clearOwner", - )(function* (clientId) { + )(function* (owner) { yield* SynchronizedRef.update(state, (current) => { + const currentOwner = current.owners.get(owner.clientId); + if ( + !currentOwner || + currentOwner.environmentId !== owner.environmentId || + currentOwner.threadId !== owner.threadId + ) { + return current; + } const owners = new Map(current.owners); - owners.delete(clientId); + owners.delete(owner.clientId); return { ...current, owners }; }); }); - const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( + const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn( "PreviewAutomationBroker.respond", )(function* (response) { const pending = yield* SynchronizedRef.modify(state, (current) => { @@ -214,16 +298,14 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { yield* Deferred.fail( pending.deferred, response.error - ? makeResponseError(response.error) - : new PreviewAutomationExecutionError({ - message: "Preview automation failed without an error payload.", - }), + ? classifyResponseError(pending.context, response.error) + : new PreviewAutomationMalformedResponseError(pending.context), ); } }); const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( - input: Parameters[0], + input: Parameters[0], ): Effect.fn.Return { const current = yield* SynchronizedRef.get(state); const candidates = Array.from(current.owners.values()) @@ -234,35 +316,62 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { owner.supportsAutomation, ) .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); - const owner = candidates[0]; + const owner = candidates.find((candidate) => current.clients.has(candidate.clientId)); if (!owner) { + const disconnectedOwner = candidates[0]; + if (disconnectedOwner) { + return yield* new PreviewAutomationHostNotConnectedError({ + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: disconnectedOwner.clientId, + }); + } return yield* new PreviewAutomationNoFocusedOwnerError({ - message: "No desktop browser host is available for this thread.", + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, }); } const connection = current.clients.get(owner.clientId); if (!connection) { - return yield* new PreviewAutomationUnavailableError({ - message: "The browser host is not connected.", - }); - } - if ( - input.operation !== "open" && - input.operation !== "status" && - !owner.tabId && - !input.tabId - ) { - return yield* new PreviewAutomationTabNotFoundError({ - message: "The browser host does not have an active tab.", + return yield* new PreviewAutomationHostNotConnectedError({ + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: owner.clientId, }); } const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make(); - const requestId = yield* SynchronizedRef.modify(state, (next) => { + const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => { const requestId = `preview-${next.requestSequence}`; + const tabId = input.tabId ?? owner.tabId ?? undefined; + const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); + const context: PreviewAutomationRequestErrorContext = { + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: owner.clientId, + requestId, + ...(tabId === undefined ? {} : { tabId }), + timeoutMs, + ...selectorDiagnostics, + }; const pending = new Map(next.pending); - pending.set(requestId, { clientId: owner.clientId, deferred }); - return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; + pending.set(requestId, { queue: connection.queue, deferred, context }); + return [ + [requestId, context] as const, + { ...next, pending, requestSequence: next.requestSequence + 1 }, + ] as const; }); const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; @@ -274,24 +383,21 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { const offered = yield* Queue.offer(connection.queue, { requestId, threadId: input.scope.threadId, - tabId: input.tabId ?? owner.tabId ?? undefined, + tabId: requestContext.tabId, operation: input.operation, input: input.input, timeoutMs, }); if (!offered) { - return yield* new PreviewAutomationUnavailableError({ - message: "The preview automation client is no longer accepting requests.", - }); + const completion = yield* Deferred.poll(deferred); + if (Option.isSome(completion)) { + return (yield* completion.value) as A; + } + return yield* new PreviewAutomationRequestQueueClosedError(requestContext); } const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); return yield* Option.match(result, { - onNone: () => - Effect.fail( - new PreviewAutomationTimeoutError({ - message: `Preview automation timed out after ${timeoutMs}ms.`, - }), - ), + onNone: () => Effect.fail(new PreviewAutomationTimeoutError(requestContext)), onSome: (value) => Effect.succeed(value as A), }); }); @@ -302,8 +408,3 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); export const layer = Layer.effect(PreviewAutomationBroker, make); - -/** Exposed for tests. */ -export const __testing = { - make, -}; diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts new file mode 100644 index 00000000000..300a50fe330 --- /dev/null +++ b/apps/server/src/observability/BrowserTraceCollector.ts @@ -0,0 +1,23 @@ +import type { TraceRecord, TraceSink } from "@t3tools/shared/observability"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export class BrowserTraceCollector extends Context.Service< + BrowserTraceCollector, + { + readonly record: (records: ReadonlyArray) => Effect.Effect; + } +>()("t3/observability/BrowserTraceCollector") {} + +export const make = (sink: TraceSink): BrowserTraceCollector["Service"] => + BrowserTraceCollector.of({ + record: (records) => + Effect.sync(() => { + for (const record of records) { + sink.push(record); + } + }), + }); + +export const layer = (sink: TraceSink) => Layer.succeed(BrowserTraceCollector, make(sink)); diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 95263866d80..11463cc1d85 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -4,17 +4,19 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; import * as Tracer from "effect/Tracer"; -import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics"; +import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; +import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; -import { ServerConfig } from "../../config.ts"; +import * as ServerConfig from "../../config.ts"; import { ServerLoggerLive } from "../../serverLogger.ts"; -import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; +import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; const otlpSerializationLayer = OtlpSerialization.layerJson; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const traceReferencesLayer = Layer.mergeAll( Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), @@ -56,14 +58,7 @@ export const ObservabilityLive = Layer.unwrap( return Layer.mergeAll( Layer.succeed(Tracer.Tracer, tracer), - Layer.succeed(BrowserTraceCollector, { - record: (records) => - Effect.sync(() => { - for (const record of records) { - sink.push(record); - } - }), - }), + BrowserTraceCollector.layer(sink), ); }), ).pipe(Layer.provideMerge(otlpSerializationLayer)); diff --git a/apps/server/src/observability/Services/BrowserTraceCollector.ts b/apps/server/src/observability/Services/BrowserTraceCollector.ts deleted file mode 100644 index b704804c963..00000000000 --- a/apps/server/src/observability/Services/BrowserTraceCollector.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { TraceRecord } from "@t3tools/shared/observability"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export interface BrowserTraceCollectorShape { - readonly record: (records: ReadonlyArray) => Effect.Effect; -} - -export class BrowserTraceCollector extends Context.Service< - BrowserTraceCollector, - BrowserTraceCollectorShape ->()("t3/observability/Services/BrowserTraceCollector") {} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 5e36f9f4bab..707c87c43c9 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { execFileSync } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import { ProviderDriverKind, @@ -30,12 +30,11 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; -import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -57,7 +56,7 @@ import { import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; +import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -199,7 +198,7 @@ async function waitForEvent( } function runGit(cwd: string, args: ReadonlyArray) { - return execFileSync("git", args, { + return NodeChildProcess.execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", @@ -207,11 +206,11 @@ function runGit(cwd: string, args: ReadonlyArray) { } function createGitRepository() { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3-checkpoint-handler-")); + const cwd = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-checkpoint-handler-")); runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); - fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v1\n", "utf8"); runGit(cwd, ["add", "."]); runGit(cwd, ["commit", "-m", "Initial"]); return cwd; @@ -247,7 +246,10 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) describe("CheckpointReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | CheckpointReactor | CheckpointStore | ProjectionSnapshotQuery, + | OrchestrationEngineService + | CheckpointReactor + | CheckpointStore.CheckpointStore + | ProjectionSnapshotQuery, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -265,7 +267,7 @@ describe("CheckpointReactor", () => { while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { - fs.rmSync(dir, { recursive: true, force: true }); + NodeFS.rmSync(dir, { recursive: true, force: true }); } } }); @@ -292,11 +294,11 @@ describe("CheckpointReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); @@ -328,14 +330,14 @@ describe("CheckpointReactor", () => { Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(vcsStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), + Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( WorkspaceEntries.layer.pipe( - Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer), ), ), - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -345,7 +347,9 @@ describe("CheckpointReactor", () => { const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); - const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); + const checkpointStore = await runtime.runPromise( + Effect.service(CheckpointStore.CheckpointStore), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -391,14 +395,14 @@ describe("CheckpointReactor", () => { checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), }), ); - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v2\n", "utf8"); await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), }), ); - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v3\n", "utf8"); await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, @@ -452,7 +456,7 @@ describe("CheckpointReactor", () => { checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-1"), @@ -550,7 +554,7 @@ describe("CheckpointReactor", () => { checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", @@ -624,7 +628,7 @@ describe("CheckpointReactor", () => { checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-claude-1"), @@ -757,7 +761,7 @@ describe("CheckpointReactor", () => { }), ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-missing-provider-cwd"), @@ -825,8 +829,8 @@ describe("CheckpointReactor", () => { }); it("continues processing runtime events after a single checkpoint runtime failure", async () => { - const nonRepositorySessionCwd = fs.mkdtempSync( - path.join(os.tmpdir(), "t3-checkpoint-runtime-non-repo-"), + const nonRepositorySessionCwd = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-checkpoint-runtime-non-repo-"), ); tempDirs.push(nonRepositorySessionCwd); @@ -959,7 +963,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.make("thread-1"), numTurns: 1, }); - expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); + expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); expect( gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), ).toBe(false); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 48ff133f56d..3ba244ddf2c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -24,7 +24,7 @@ import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd, } from "../../checkpointing/Utils.ts"; -import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -81,7 +81,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; - const checkpointStore = yield* CheckpointStore; + const checkpointStore = yield* CheckpointStore.CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index aa2ca2bb299..549e92894b0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -27,7 +27,7 @@ import { OrchestrationEventStore, type OrchestrationEventStoreShape, } from "../../persistence/Services/OrchestrationEventStore.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -57,7 +57,7 @@ async function createOrchestrationSystem() { ).pipe( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -681,7 +681,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -786,7 +786,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provide(NodeServices.layer), ), @@ -929,7 +929,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provide(NodeServices.layer), ), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 5a997de3669..0999000ed4f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -24,7 +24,7 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { ORCHESTRATION_PROJECTOR_NAMES, @@ -2535,7 +2535,7 @@ const engineLayer = it.layer( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b033460e40b..b574a99237e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -14,8 +14,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; -import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -28,7 +27,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( - Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), ), @@ -1443,7 +1442,7 @@ it.effect( const resolveCalls: string[] = []; const layer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provideMerge( - Layer.succeed(RepositoryIdentityResolver, { + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (cwd: string) => Effect.sync(() => { resolveCalls.push(cwd); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 23128ff5469..629af717751 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -49,7 +49,7 @@ import { ProjectionThreadProposedPlan } from "../../persistence/Services/Project import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThreadGoalRepository } from "../../persistence/Services/ProjectionThreadGoals.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; -import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -265,8 +265,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const projectionThreadGoalRepository = yield* ProjectionThreadGoalRepository; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; const resolveRepositoryIdentitiesForProjects = Effect.fn( "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 1eff60b603a..fd237a8bac0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { ModelSelection, @@ -43,7 +43,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -60,7 +60,7 @@ import * as Clock from "effect/Clock"; import { LaunchEnvLive } from "../../launchEnv/Layers/LaunchEnvLive.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; -import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; +import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -72,7 +72,7 @@ const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => async function waitFor( predicate: () => boolean | Promise, - timeoutMs = 2000, + timeoutMs = 10_000, ): Promise { const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; const poll = async (): Promise => { @@ -108,11 +108,11 @@ describe("ProviderCommandReactor", () => { } runtime = null; for (const stateDir of createdStateDirs) { - fs.rmSync(stateDir, { recursive: true, force: true }); + NodeFS.rmSync(stateDir, { recursive: true, force: true }); } createdStateDirs.clear(); for (const baseDir of createdBaseDirs) { - fs.rmSync(baseDir, { recursive: true, force: true }); + NodeFS.rmSync(baseDir, { recursive: true, force: true }); } createdBaseDirs.clear(); }); @@ -148,7 +148,8 @@ describe("ProviderCommandReactor", () => { readonly requiresNewThreadForModelChange?: boolean; }) { const now = "2026-01-01T00:00:00.000Z"; - const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); + const baseDir = + input?.baseDir ?? NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-reactor-")); createdBaseDirs.add(baseDir); const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); @@ -336,11 +337,11 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); const serverConfigLayer = ServerConfig.layerTest(process.cwd(), baseDir).pipe( @@ -358,9 +359,9 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), Layer.provideMerge( - Layer.mock(GitWorkflowService)({ + Layer.mock(GitWorkflowService.GitWorkflowService)({ renameBranch, - } satisfies Partial), + } satisfies Partial), ), Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 2c54d4156e5..f908c2c2292 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { OrchestrationReadModel, @@ -39,7 +39,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -202,7 +202,7 @@ describe("ProviderRuntimeIngestion", () => { const tempDirs: string[] = []; function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), prefix)); tempDirs.push(dir); return dir; } @@ -217,24 +217,24 @@ describe("ProviderRuntimeIngestion", () => { } runtime = null; for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); + NodeFS.rmSync(dir, { recursive: true, force: true }); } }); async function createHarness(options?: { serverSettings?: Partial }) { const workspaceRoot = makeTempDir("t3-provider-project-"); - fs.mkdirSync(path.join(workspaceRoot, ".git")); + NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderRuntimeIngestionLive.pipe( diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 4bbf5ca2149..7d8a24069a3 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor, @@ -39,7 +39,7 @@ export const logCleanupCauseUnlessInterrupted = ({ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const terminalManager = yield* TerminalManager; + const terminalManager = yield* TerminalManager.TerminalManager; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 95d29e3d6d2..bed166eba45 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -11,14 +11,14 @@ import { import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const normalizeProjectWorkspaceRoot = (workspaceRoot: string) => workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe( diff --git a/apps/server/src/pathExpansion.test.ts b/apps/server/src/pathExpansion.test.ts index a6f004d4e6f..cc7c85786da 100644 --- a/apps/server/src/pathExpansion.test.ts +++ b/apps/server/src/pathExpansion.test.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import { homedir } from "node:os"; -import { join } from "node:path"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { expandHomePath } from "./pathExpansion.ts"; @@ -17,15 +17,15 @@ describe("expandHomePath", () => { }); it("expands a lone tilde to the home directory", () => { - expect(expandHomePath("~")).toBe(homedir()); + expect(expandHomePath("~")).toBe(NodeOS.homedir()); }); it("expands ~/ to a subpath of the home directory", () => { - expect(expandHomePath("~/.codex-work")).toBe(join(homedir(), ".codex-work")); + expect(expandHomePath("~/.codex-work")).toBe(NodePath.join(NodeOS.homedir(), ".codex-work")); }); it("expands a Windows-style ~\\ prefix", () => { - expect(expandHomePath("~\\.codex")).toBe(join(homedir(), ".codex")); + expect(expandHomePath("~\\.codex")).toBe(NodePath.join(NodeOS.homedir(), ".codex")); }); it("does not expand ~user paths", () => { diff --git a/apps/server/src/pathExpansion.ts b/apps/server/src/pathExpansion.ts index 170d83c54d0..bacdaece0b1 100644 --- a/apps/server/src/pathExpansion.ts +++ b/apps/server/src/pathExpansion.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import { homedir } from "node:os"; -import { join } from "node:path"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; /** * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to the @@ -16,9 +16,9 @@ import { join } from "node:path"; */ export function expandHomePath(value: string): string { if (!value) return value; - if (value === "~") return homedir(); + if (value === "~") return NodeOS.homedir(); if (value.startsWith("~/") || value.startsWith("~\\")) { - return join(homedir(), value.slice(2)); + return NodePath.join(NodeOS.homedir(), value.slice(2)); } return value; } diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts new file mode 100644 index 00000000000..e54c977e7ab --- /dev/null +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -0,0 +1,356 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { AuthEnvironmentScopes } from "@t3tools/contracts"; + +import { + type AuthPairingLinkRepositoryError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, +} from "./Errors.ts"; + +export const AuthPairingLinkRecord = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + scopes: Schema.fromJsonString(AuthEnvironmentScopes), + subject: Schema.String, + label: Schema.NullOr(Schema.String), + proofKeyThumbprint: Schema.NullOr(Schema.String), + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; + +export const CreateAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + scopes: AuthEnvironmentScopes, + subject: Schema.String, + label: Schema.NullOr(Schema.String), + proofKeyThumbprint: Schema.NullOr(Schema.String), + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; + +export const ConsumeAuthPairingLinkInput = Schema.Struct({ + credential: Schema.String, + proofKeyThumbprint: Schema.NullOr(Schema.String), + consumedAt: Schema.DateTimeUtcFromString, + now: Schema.DateTimeUtcFromString, +}); +export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; + +export const ListActiveAuthPairingLinksInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; + +export const RevokeAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; + +export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ + credential: Schema.String, +}); +export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; + +const AuthPairingLinkRawDbRow = Schema.Struct({ + id: Schema.String, + credential: Schema.Unknown, + method: Schema.Unknown, + scopes: Schema.Unknown, + subject: Schema.Unknown, + label: Schema.Unknown, + proofKeyThumbprint: Schema.Unknown, + createdAt: Schema.Unknown, + expiresAt: Schema.Unknown, + consumedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthPairingLinkDbRow = Schema.decodeUnknownEffect(AuthPairingLinkRecord); + +export class AuthPairingLinkRepository extends Context.Service< + AuthPairingLinkRepository, + { + readonly create: ( + input: CreateAuthPairingLinkInput, + ) => Effect.Effect; + readonly consumeAvailable: ( + input: ConsumeAuthPairingLinkInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly listActive: ( + input: ListActiveAuthPairingLinksInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly revoke: ( + input: RevokeAuthPairingLinkInput, + ) => Effect.Effect; + readonly getByCredential: ( + input: GetAuthPairingLinkByCredentialInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + } +>()("t3/persistence/AuthPairingLinks/AuthPairingLinkRepository") {} + +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { + return (cause: unknown): AuthPairingLinkRepositoryError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createPairingLinkRow = SqlSchema.void({ + Request: CreateAuthPairingLinkInput, + execute: (input) => + sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + scopes, + subject, + label, + proof_key_thumbprint, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${input.id}, + ${input.credential}, + ${input.method}, + ${JSON.stringify(input.scopes)}, + ${input.subject}, + ${input.label}, + ${input.proofKeyThumbprint}, + ${input.createdAt}, + ${input.expiresAt}, + NULL, + NULL + ) + `, + }); + + const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ + Request: ConsumeAuthPairingLinkInput, + Result: AuthPairingLinkRawDbRow, + execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => + sql` + UPDATE auth_pairing_links + SET consumed_at = ${consumedAt} + WHERE credential = ${credential} + AND revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + AND ( + proof_key_thumbprint IS NULL + OR proof_key_thumbprint = ${proofKeyThumbprint} + ) + RETURNING + id AS "id", + credential AS "credential", + method AS "method", + scopes AS "scopes", + subject AS "subject", + label AS "label", + proof_key_thumbprint AS "proofKeyThumbprint", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + `, + }); + + const listActivePairingLinkRows = SqlSchema.findAll({ + Request: ListActiveAuthPairingLinksInput, + Result: AuthPairingLinkRawDbRow, + execute: ({ now }) => + sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + scopes AS "scopes", + subject AS "subject", + label AS "label", + proof_key_thumbprint AS "proofKeyThumbprint", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + FROM auth_pairing_links + WHERE revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + ORDER BY created_at DESC, id DESC + `, + }); + + const revokePairingLinkRow = SqlSchema.findAll({ + Request: RevokeAuthPairingLinkInput, + Result: Schema.Struct({ id: Schema.String }), + execute: ({ id, revokedAt }) => + sql` + UPDATE auth_pairing_links + SET revoked_at = ${revokedAt} + WHERE id = ${id} + AND revoked_at IS NULL + AND consumed_at IS NULL + RETURNING id AS "id" + `, + }); + + const getPairingLinkRowByCredential = SqlSchema.findOneOption({ + Request: GetAuthPairingLinkByCredentialInput, + Result: AuthPairingLinkRawDbRow, + execute: ({ credential }) => + sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + scopes AS "scopes", + subject AS "subject", + label AS "label", + proof_key_thumbprint AS "proofKeyThumbprint", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + FROM auth_pairing_links + WHERE credential = ${credential} + `, + }); + + const create: AuthPairingLinkRepository["Service"]["create"] = (input) => + createPairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.create:query", + "AuthPairingLinkRepository.create:encodeRequest", + { pairingLinkId: input.id }, + ), + ), + ); + + const consumeAvailable: AuthPairingLinkRepository["Service"]["consumeAvailable"] = (input) => + consumeAvailablePairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.consumeAvailable:query", + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), + ); + + const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) => + listActivePairingLinkRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.listActive:query", + "AuthPairingLinkRepository.listActive:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.listActive:decodeRows", + cause, + { pairingLinkId: row.id }, + ), + ), + ), + ), + ), + ); + + const revoke: AuthPairingLinkRepository["Service"]["revoke"] = (input) => + revokePairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.revoke:query", + "AuthPairingLinkRepository.revoke:decodeRows", + { pairingLinkId: input.id }, + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const getByCredential: AuthPairingLinkRepository["Service"]["getByCredential"] = (input) => + getPairingLinkRowByCredential(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.getByCredential:query", + "AuthPairingLinkRepository.getByCredential:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.getByCredential:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), + ); + + return { + create, + consumeAvailable, + listActive, + revoke, + getByCredential, + } satisfies AuthPairingLinkRepository["Service"]; +}); + +export const layer = Layer.effect(AuthPairingLinkRepository, make); diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts similarity index 53% rename from apps/server/src/persistence/Layers/AuthSessions.ts rename to apps/server/src/persistence/AuthSessions.ts index ab84e3fa041..545688e3822 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -1,27 +1,110 @@ -import { AuthEnvironmentScopes, AuthSessionId, ServerAuthSessionMethod } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { - toPersistenceDecodeError, - toPersistenceSqlError, - type AuthSessionRepositoryError, -} from "../Errors.ts"; + AuthClientMetadataDeviceType, + AuthEnvironmentScopes, + AuthSessionId, + ServerAuthSessionMethod, +} from "@t3tools/contracts"; + import { - AuthSessionRecord, + type AuthSessionRepositoryError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, +} from "./Errors.ts"; + +export const AuthSessionClientMetadataRecord = Schema.Struct({ + label: Schema.NullOr(Schema.String), + ipAddress: Schema.NullOr(Schema.String), + userAgent: Schema.NullOr(Schema.String), + deviceType: AuthClientMetadataDeviceType, + os: Schema.NullOr(Schema.String), + browser: Schema.NullOr(Schema.String), +}); +export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; + +export const AuthSessionRecord = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + scopes: AuthEnvironmentScopes, + method: ServerAuthSessionMethod, + client: AuthSessionClientMetadataRecord, + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthSessionRecord = typeof AuthSessionRecord.Type; + +export const CreateAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + scopes: AuthEnvironmentScopes, + method: ServerAuthSessionMethod, + client: AuthSessionClientMetadataRecord, + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; + +export const GetAuthSessionByIdInput = Schema.Struct({ + sessionId: AuthSessionId, +}); +export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; + +export const ListActiveAuthSessionsInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; + +export const RevokeAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; + +export const RevokeOtherAuthSessionsInput = Schema.Struct({ + currentSessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; + +export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ + sessionId: AuthSessionId, + lastConnectedAt: Schema.DateTimeUtcFromString, +}); +export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; + +export class AuthSessionRepository extends Context.Service< AuthSessionRepository, - type AuthSessionRepositoryShape, - CreateAuthSessionInput, - GetAuthSessionByIdInput, - ListActiveAuthSessionsInput, - RevokeAuthSessionInput, - RevokeOtherAuthSessionsInput, - SetAuthSessionLastConnectedAtInput, -} from "../Services/AuthSessions.ts"; + { + readonly create: ( + input: CreateAuthSessionInput, + ) => Effect.Effect; + readonly getById: ( + input: GetAuthSessionByIdInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly listActive: ( + input: ListActiveAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly revoke: ( + input: RevokeAuthSessionInput, + ) => Effect.Effect; + readonly revokeAllExcept: ( + input: RevokeOtherAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly setLastConnectedAt: ( + input: SetAuthSessionLastConnectedAtInput, + ) => Effect.Effect; + } +>()("t3/persistence/AuthSessions/AuthSessionRepository") {} const AuthSessionDbRow = Schema.Struct({ sessionId: AuthSessionId, @@ -40,6 +123,25 @@ const AuthSessionDbRow = Schema.Struct({ revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), }); +const AuthSessionRawDbRow = Schema.Struct({ + sessionId: Schema.String, + subject: Schema.Unknown, + scopes: Schema.Unknown, + method: Schema.Unknown, + clientLabel: Schema.Unknown, + clientIpAddress: Schema.Unknown, + clientUserAgent: Schema.Unknown, + clientDeviceType: Schema.Unknown, + clientOs: Schema.Unknown, + clientBrowser: Schema.Unknown, + issuedAt: Schema.Unknown, + expiresAt: Schema.Unknown, + lastConnectedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow); + function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord { return { sessionId: row.sessionId, @@ -61,14 +163,22 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco }; } -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthSessionRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } -const makeAuthSessionRepository = Effect.gen(function* () { +export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const createSessionRow = SqlSchema.void({ @@ -110,7 +220,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { const getSessionRowById = SqlSchema.findOneOption({ Request: GetAuthSessionByIdInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ sessionId }) => sql` SELECT @@ -135,7 +245,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { const listActiveSessionRows = SqlSchema.findAll({ Request: ListActiveAuthSessionsInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ now }) => sql` SELECT @@ -197,33 +307,45 @@ const makeAuthSessionRepository = Effect.gen(function* () { `, }); - const create: AuthSessionRepositoryShape["create"] = (input) => + const create: AuthSessionRepository["Service"]["create"] = (input) => createSessionRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthSessionRepository.create:query", "AuthSessionRepository.create:encodeRequest", + { sessionId: input.sessionId }, ), ), ); - const getById: AuthSessionRepositoryShape["getById"] = (input) => + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthSessionRepository.getById:query", "AuthSessionRepository.getById:decodeRow", + { sessionId: input.sessionId }, ), ), Effect.flatMap((rowOption) => Option.match(rowOption, { onNone: () => Effect.succeed(Option.none()), - onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))), + onSome: (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.getById:decodeRow", + cause, + { sessionId: input.sessionId }, + ), + ), + Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))), + ), }), ), ); - const listActive: AuthSessionRepositoryShape["listActive"] = (input) => + const listActive: AuthSessionRepository["Service"]["listActive"] = (input) => listActiveSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -231,37 +353,53 @@ const makeAuthSessionRepository = Effect.gen(function* () { "AuthSessionRepository.listActive:decodeRows", ), ), - Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.listActive:decodeRows", + cause, + { sessionId: row.sessionId }, + ), + ), + Effect.map(toAuthSessionRecord), + ), + ), + ), ); - const revoke: AuthSessionRepositoryShape["revoke"] = (input) => + const revoke: AuthSessionRepository["Service"]["revoke"] = (input) => revokeSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthSessionRepository.revoke:query", "AuthSessionRepository.revoke:decodeRows", + { sessionId: input.sessionId }, ), ), Effect.map((rows) => rows.length > 0), ); - const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) => + const revokeAllExcept: AuthSessionRepository["Service"]["revokeAllExcept"] = (input) => revokeOtherSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthSessionRepository.revokeAllExcept:query", "AuthSessionRepository.revokeAllExcept:decodeRows", + { currentSessionId: input.currentSessionId }, ), ), Effect.map((rows) => rows.map((row) => row.sessionId)), ); - const setLastConnectedAt: AuthSessionRepositoryShape["setLastConnectedAt"] = (input) => + const setLastConnectedAt: AuthSessionRepository["Service"]["setLastConnectedAt"] = (input) => setLastConnectedAtRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "AuthSessionRepository.setLastConnectedAt:query", "AuthSessionRepository.setLastConnectedAt:encodeRequest", + { sessionId: input.sessionId }, ), ), ); @@ -273,10 +411,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { revoke, revokeAllExcept, setLastConnectedAt, - } satisfies AuthSessionRepositoryShape; + } satisfies AuthSessionRepository["Service"]; }); -export const AuthSessionRepositoryLive = Layer.effect( - AuthSessionRepository, - makeAuthSessionRepository, -); +export const layer = Layer.effect(AuthSessionRepository, make); diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts new file mode 100644 index 00000000000..680a362e20a --- /dev/null +++ b/apps/server/src/persistence/Errors.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts"; + +const decodeRuntimePayload = Schema.decodeUnknownEffect( + Schema.Struct({ + runtimePayload: Schema.Struct({ + attempt: Schema.Number, + }), + }), +); + +it("keeps SQL operation context without a tautological detail", () => { + const cause = new Error("database unavailable"); + const error = new PersistenceSqlError({ + operation: "AuthSessionRepository.list:query", + cause, + }); + + assert.equal(error.operation, "AuthSessionRepository.list:query"); + assert.equal(error.detail, undefined); + assert.equal(error.cause, cause); + assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); +}); + +it.effect("maps schema errors without copying rejected payloads into diagnostics", () => + Effect.gen(function* () { + const rejectedPayload = "runtime-payload-secret-sentinel"; + const cause = yield* Effect.flip( + decodeRuntimePayload({ + runtimePayload: { + attempt: rejectedPayload, + }, + }), + ); + const error = PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + ); + + assert.equal(error.operation, "ProviderSessionRuntimeRepository.list:decodeRows"); + assert.equal(error.cause, cause); + assert.notInclude(error.issue, rejectedPayload); + assert.notInclude(error.message, rejectedPayload); + assert.include(error.issue, "InvalidType"); + }), +); diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 2a3d7aff189..03edaec77d6 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,20 +1,45 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; +function summarizeSchemaIssue(issue: SchemaIssue.Issue): string { + switch (issue._tag) { + case "Filter": + case "Encoding": + case "Pointer": + return `${issue._tag}(${summarizeSchemaIssue(issue.issue)})`; + case "Composite": + case "AnyOf": + return `${issue._tag}(${issue.issues.map(summarizeSchemaIssue).join(",")})`; + default: + return issue._tag; + } +} + // =============================== // Core Persistence Errors // =============================== +export const PersistenceErrorCorrelation = Schema.Union([ + Schema.Struct({ sessionId: Schema.String }), + Schema.Struct({ currentSessionId: Schema.String }), + Schema.Struct({ pairingLinkId: Schema.String }), + Schema.Struct({ threadId: Schema.String }), +]); +export type PersistenceErrorCorrelation = typeof PersistenceErrorCorrelation.Type; + export class PersistenceSqlError extends Schema.TaggedErrorClass()( "PersistenceSqlError", { operation: Schema.String, - detail: Schema.String, + detail: Schema.optional(Schema.String), + correlation: Schema.optional(PersistenceErrorCorrelation), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `SQL error in ${this.operation}: ${this.detail}`; + return this.detail === undefined + ? `SQL error in ${this.operation}` + : `SQL error in ${this.operation}: ${this.detail}`; } } @@ -23,9 +48,23 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass new PersistenceSqlError({ @@ -42,22 +82,10 @@ export function toPersistenceSqlError(operation: string) { }); } +// Kept for orchestration/projection call sites, which are being revamped separately. export function toPersistenceDecodeError(operation: string) { - return (error: Schema.SchemaError): PersistenceDecodeError => - new PersistenceDecodeError({ - operation, - issue: SchemaIssue.makeFormatterDefault()(error.issue), - cause: error, - }); -} - -export function toPersistenceDecodeCauseError(operation: string) { - return (cause: unknown): PersistenceDecodeError => - new PersistenceDecodeError({ - operation, - issue: `Failed to execute ${operation}`, - cause, - }); + return (cause: Schema.SchemaError): PersistenceDecodeError => + PersistenceDecodeError.fromSchemaError(operation, cause); } export const isPersistenceError = (u: unknown) => diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts deleted file mode 100644 index 9d2760d1449..00000000000 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ /dev/null @@ -1,220 +0,0 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; - -import { - toPersistenceDecodeError, - toPersistenceSqlError, - type AuthPairingLinkRepositoryError, -} from "../Errors.ts"; -import { - AuthPairingLinkRecord, - AuthPairingLinkRepository, - type AuthPairingLinkRepositoryShape, - ConsumeAuthPairingLinkInput, - CreateAuthPairingLinkInput, - GetAuthPairingLinkByCredentialInput, - ListActiveAuthPairingLinksInput, - RevokeAuthPairingLinkInput, -} from "../Services/AuthPairingLinks.ts"; - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown): AuthPairingLinkRepositoryError => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeAuthPairingLinkRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const createPairingLinkRow = SqlSchema.void({ - Request: CreateAuthPairingLinkInput, - execute: (input) => - sql` - INSERT INTO auth_pairing_links ( - id, - credential, - method, - scopes, - subject, - label, - proof_key_thumbprint, - created_at, - expires_at, - consumed_at, - revoked_at - ) - VALUES ( - ${input.id}, - ${input.credential}, - ${input.method}, - ${JSON.stringify(input.scopes)}, - ${input.subject}, - ${input.label}, - ${input.proofKeyThumbprint}, - ${input.createdAt}, - ${input.expiresAt}, - NULL, - NULL - ) - `, - }); - - const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ - Request: ConsumeAuthPairingLinkInput, - Result: AuthPairingLinkRecord, - execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => - sql` - UPDATE auth_pairing_links - SET consumed_at = ${consumedAt} - WHERE credential = ${credential} - AND revoked_at IS NULL - AND consumed_at IS NULL - AND expires_at > ${now} - AND ( - proof_key_thumbprint IS NULL - OR proof_key_thumbprint = ${proofKeyThumbprint} - ) - RETURNING - id AS "id", - credential AS "credential", - method AS "method", - scopes AS "scopes", - subject AS "subject", - label AS "label", - proof_key_thumbprint AS "proofKeyThumbprint", - created_at AS "createdAt", - expires_at AS "expiresAt", - consumed_at AS "consumedAt", - revoked_at AS "revokedAt" - `, - }); - - const listActivePairingLinkRows = SqlSchema.findAll({ - Request: ListActiveAuthPairingLinksInput, - Result: AuthPairingLinkRecord, - execute: ({ now }) => - sql` - SELECT - id AS "id", - credential AS "credential", - method AS "method", - scopes AS "scopes", - subject AS "subject", - label AS "label", - proof_key_thumbprint AS "proofKeyThumbprint", - created_at AS "createdAt", - expires_at AS "expiresAt", - consumed_at AS "consumedAt", - revoked_at AS "revokedAt" - FROM auth_pairing_links - WHERE revoked_at IS NULL - AND consumed_at IS NULL - AND expires_at > ${now} - ORDER BY created_at DESC, id DESC - `, - }); - - const revokePairingLinkRow = SqlSchema.findAll({ - Request: RevokeAuthPairingLinkInput, - Result: Schema.Struct({ id: Schema.String }), - execute: ({ id, revokedAt }) => - sql` - UPDATE auth_pairing_links - SET revoked_at = ${revokedAt} - WHERE id = ${id} - AND revoked_at IS NULL - AND consumed_at IS NULL - RETURNING id AS "id" - `, - }); - - const getPairingLinkRowByCredential = SqlSchema.findOneOption({ - Request: GetAuthPairingLinkByCredentialInput, - Result: AuthPairingLinkRecord, - execute: ({ credential }) => - sql` - SELECT - id AS "id", - credential AS "credential", - method AS "method", - scopes AS "scopes", - subject AS "subject", - label AS "label", - proof_key_thumbprint AS "proofKeyThumbprint", - created_at AS "createdAt", - expires_at AS "expiresAt", - consumed_at AS "consumedAt", - revoked_at AS "revokedAt" - FROM auth_pairing_links - WHERE credential = ${credential} - `, - }); - - const create: AuthPairingLinkRepositoryShape["create"] = (input) => - createPairingLinkRow(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "AuthPairingLinkRepository.create:query", - "AuthPairingLinkRepository.create:encodeRequest", - ), - ), - ); - - const consumeAvailable: AuthPairingLinkRepositoryShape["consumeAvailable"] = (input) => - consumeAvailablePairingLinkRow(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "AuthPairingLinkRepository.consumeAvailable:query", - "AuthPairingLinkRepository.consumeAvailable:decodeRow", - ), - ), - ); - - const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => - listActivePairingLinkRows(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "AuthPairingLinkRepository.listActive:query", - "AuthPairingLinkRepository.listActive:decodeRows", - ), - ), - ); - - const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => - revokePairingLinkRow(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "AuthPairingLinkRepository.revoke:query", - "AuthPairingLinkRepository.revoke:decodeRows", - ), - ), - Effect.map((rows) => rows.length > 0), - ); - - const getByCredential: AuthPairingLinkRepositoryShape["getByCredential"] = (input) => - getPairingLinkRowByCredential(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "AuthPairingLinkRepository.getByCredential:query", - "AuthPairingLinkRepository.getByCredential:decodeRow", - ), - ), - ); - - return { - create, - consumeAvailable, - listActive, - revoke, - getByCredential, - } satisfies AuthPairingLinkRepositoryShape; -}); - -export const AuthPairingLinkRepositoryLive = Layer.effect( - AuthPairingLinkRepository, - makeAuthPairingLinkRepository, -); diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 9ee5c82bb53..52e4f8f7408 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -1,208 +1,2 @@ -import { ThreadId } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; - -import { - toPersistenceDecodeError, - toPersistenceSqlError, - type ProviderSessionRuntimeRepositoryError, -} from "../Errors.ts"; -import { - ProviderSessionRuntime, - ProviderSessionRuntimeRepository, - type ProviderSessionRuntimeRepositoryShape, -} from "../Services/ProviderSessionRuntime.ts"; - -const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( - Struct.assign({ - resumeCursor: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - runtimePayload: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - }), -); - -const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); - -const GetRuntimeRequestSchema = Schema.Struct({ - threadId: ThreadId, -}); - -const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown): ProviderSessionRuntimeRepositoryError => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeProviderSessionRuntimeRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertRuntimeRow = SqlSchema.void({ - Request: ProviderSessionRuntimeDbRowSchema, - execute: (runtime) => - sql` - INSERT INTO provider_session_runtime ( - thread_id, - provider_name, - provider_instance_id, - adapter_key, - runtime_mode, - status, - last_seen_at, - resume_cursor_json, - runtime_payload_json - ) - VALUES ( - ${runtime.threadId}, - ${runtime.providerName}, - ${runtime.providerInstanceId}, - ${runtime.adapterKey}, - ${runtime.runtimeMode}, - ${runtime.status}, - ${runtime.lastSeenAt}, - ${runtime.resumeCursor}, - ${runtime.runtimePayload} - ) - ON CONFLICT (thread_id) - DO UPDATE SET - provider_name = excluded.provider_name, - provider_instance_id = excluded.provider_instance_id, - adapter_key = excluded.adapter_key, - runtime_mode = excluded.runtime_mode, - status = excluded.status, - last_seen_at = excluded.last_seen_at, - resume_cursor_json = excluded.resume_cursor_json, - runtime_payload_json = excluded.runtime_payload_json - `, - }); - - const getRuntimeRowByThreadId = SqlSchema.findOneOption({ - Request: GetRuntimeRequestSchema, - Result: ProviderSessionRuntimeDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - provider_name AS "providerName", - provider_instance_id AS "providerInstanceId", - adapter_key AS "adapterKey", - runtime_mode AS "runtimeMode", - status, - last_seen_at AS "lastSeenAt", - resume_cursor_json AS "resumeCursor", - runtime_payload_json AS "runtimePayload" - FROM provider_session_runtime - WHERE thread_id = ${threadId} - `, - }); - - const listRuntimeRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProviderSessionRuntimeDbRowSchema, - execute: () => - sql` - SELECT - thread_id AS "threadId", - provider_name AS "providerName", - provider_instance_id AS "providerInstanceId", - adapter_key AS "adapterKey", - runtime_mode AS "runtimeMode", - status, - last_seen_at AS "lastSeenAt", - resume_cursor_json AS "resumeCursor", - runtime_payload_json AS "runtimePayload" - FROM provider_session_runtime - ORDER BY last_seen_at ASC, thread_id ASC - `, - }); - - const deleteRuntimeByThreadId = SqlSchema.void({ - Request: DeleteRuntimeRequestSchema, - execute: ({ threadId }) => - sql` - DELETE FROM provider_session_runtime - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProviderSessionRuntimeRepositoryShape["upsert"] = (runtime) => - upsertRuntimeRow(runtime).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.upsert:query", - "ProviderSessionRuntimeRepository.upsert:encodeRequest", - ), - ), - ); - - const getByThreadId: ProviderSessionRuntimeRepositoryShape["getByThreadId"] = (input) => - getRuntimeRowByThreadId(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.getByThreadId:query", - "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", - ), - ), - Effect.flatMap((runtimeRowOption) => - Option.match(runtimeRowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", - ), - ), - Effect.map((runtime) => Option.some(runtime)), - ), - }), - ), - ); - - const list: ProviderSessionRuntimeRepositoryShape["list"] = () => - listRuntimeRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.list:query", - "ProviderSessionRuntimeRepository.list:decodeRows", - ), - ), - Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), - ), - ), - { concurrency: "unbounded" }, - ), - ), - ); - - const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => - deleteRuntimeByThreadId(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - getByThreadId, - list, - deleteByThreadId, - } satisfies ProviderSessionRuntimeRepositoryShape; -}); - -export const ProviderSessionRuntimeRepositoryLive = Layer.effect( - ProviderSessionRuntimeRepository, - makeProviderSessionRuntimeRepository, -); +/** @deprecated Compatibility alias for the excluded orchestration integration harness. */ +export { layer as ProviderSessionRuntimeRepositoryLive } from "../ProviderSessionRuntime.ts"; diff --git a/apps/server/src/persistence/NodeSqliteClient.test.ts b/apps/server/src/persistence/NodeSqliteClient.test.ts index 43023abf60a..ce52c36d84c 100644 --- a/apps/server/src/persistence/NodeSqliteClient.test.ts +++ b/apps/server/src/persistence/NodeSqliteClient.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqliteClient from "./NodeSqliteClient.ts"; @@ -27,4 +28,25 @@ layer("NodeSqliteClient", (it) => { assert.equal(values[1]?.[1], "beta"); }), ); + + it.effect("returns a typed failure when an unprepared statement cannot be prepared", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const error = yield* Effect.flip(sql.unsafe("SELECT FROM").unprepared); + + assert.equal(error._tag, "SqlError"); + assert.equal(error.reason.operation, "prepare"); + }), + ); }); + +it.effect("returns a typed failure when the database cannot be opened", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Layer.build(SqliteClient.layer({ filename: "\0" })).pipe(Effect.scoped), + ); + + assert.equal(error._tag, "SqlError"); + assert.equal(error.reason.operation, "open"); + }), +); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 6b91b5bd07b..16d5762a1fe 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -4,7 +4,7 @@ * * @module SqliteClient */ -import { DatabaseSync, type StatementSync } from "node:sqlite"; +import * as NodeSqlite from "node:sqlite"; import * as Cache from "effect/Cache"; import * as Config from "effect/Config"; @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Context from "effect/Context"; @@ -29,11 +30,6 @@ export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; export type TypeId = "~local/sqlite-node/SqliteClient"; -/** - * SqliteClient - Effect service tag for the sqlite SQL client. - */ -export const SqliteClient = Context.Service("t3/persistence/NodeSqliteClient"); - export interface SqliteClientConfig { readonly filename: string; readonly readonly?: boolean | undefined; @@ -50,6 +46,27 @@ export interface SqliteMemoryClientConfig extends Omit< "filename" | "readonly" > {} +export class UnsupportedNodeSqliteVersionError extends Schema.TaggedErrorClass()( + "UnsupportedNodeSqliteVersionError", + { + nodeVersion: Schema.String, + requirement: Schema.String, + }, +) { + override get message(): string { + return `Node.js ${this.nodeVersion} is missing required node:sqlite APIs. Upgrade to ${this.requirement}.`; + } +} + +export class UnsupportedNodeSqliteOperationError extends Schema.TaggedErrorClass()( + "UnsupportedNodeSqliteOperationError", + {}, +) { + override get message(): string { + return "Node SQLite does not support executeStream."; + } +} + /** * Verify that the current Node.js version includes the `node:sqlite` APIs * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added @@ -65,8 +82,10 @@ const checkNodeSqliteCompat = () => { if (!supported) { return Effect.die( - `Node.js ${process.versions.node} is missing required node:sqlite APIs ` + - `(StatementSync.columns). Upgrade to Node.js >=22.16, >=23.11, or >=24.`, + new UnsupportedNodeSqliteVersionError({ + nodeVersion: process.versions.node, + requirement: "Node.js >=22.16, >=23.11, or >=24", + }), ); } return Effect.void; @@ -74,8 +93,8 @@ const checkNodeSqliteCompat = () => { const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( options: SqliteClientConfig, - openDatabase: () => DatabaseSync, -): Effect.fn.Return { + openDatabase: () => NodeSqlite.DatabaseSync, +): Effect.fn.Return { yield* checkNodeSqliteCompat(); const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); @@ -85,14 +104,32 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( const makeConnection = Effect.gen(function* () { const scope = yield* Effect.scope; - const db = openDatabase(); + const db = yield* Effect.try({ + try: openDatabase, + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to open database", + operation: "open", + }), + }), + }); yield* Scope.addFinalizer( scope, - Effect.sync(() => db.close()), + Effect.try({ + try: () => db.close(), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to close database", + operation: "close", + }), + }), + }).pipe(Effect.orDie), ); - const statementReaderCache = new WeakMap(); - const hasRows = (statement: StatementSync): boolean => { + const statementReaderCache = new WeakMap(); + const hasRows = (statement: NodeSqlite.StatementSync): boolean => { const cached = statementReaderCache.get(statement); if (cached !== undefined) { return cached; @@ -118,10 +155,14 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( }), }); - const runStatement = (statement: StatementSync, params: ReadonlyArray, raw: boolean) => + const runStatement = ( + statement: NodeSqlite.StatementSync, + params: ReadonlyArray, + raw: boolean, + ) => Effect.withFiber, SqlError>((fiber) => { - statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); try { + statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); if (hasRows(statement)) { return Effect.succeed(statement.all(...(params as any))); } @@ -167,11 +208,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( }), }), (statement) => - Effect.sync(() => { - if (hasRows(statement)) { - statement.setReturnArrays(false); - } - }), + Effect.try({ + try: () => { + if (hasRows(statement)) { + statement.setReturnArrays(false); + } + }, + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to reset statement result mode", + operation: "resetResultMode", + }), + }), + }).pipe(Effect.orDie), ); return identity({ @@ -185,11 +235,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( return runValues(sql, params); }, executeUnprepared(sql, params, rowTransform) { - const effect = runStatement(db.prepare(sql), params ?? [], false); + const effect = Effect.try({ + try: () => db.prepare(sql), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to prepare statement", + operation: "prepare", + }), + }), + }).pipe(Effect.flatMap((statement) => runStatement(statement, params ?? [], false))); return rowTransform ? Effect.map(effect, rowTransform) : effect; }, executeStream(_sql, _params) { - return Stream.die("executeStream not implemented"); + return Stream.die(new UnsupportedNodeSqliteOperationError()); }, }); }); @@ -221,11 +280,11 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( const make = ( options: SqliteClientConfig, -): Effect.Effect => +): Effect.Effect => makeWithDatabase( options, () => - new DatabaseSync(options.filename, { + new NodeSqlite.DatabaseSync(options.filename, { readOnly: options.readonly ?? false, allowExtension: options.allowExtension ?? false, }), @@ -233,7 +292,7 @@ const make = ( const makeMemory = ( config: SqliteMemoryClientConfig = {}, -): Effect.Effect => +): Effect.Effect => makeWithDatabase( { ...config, @@ -241,7 +300,7 @@ const makeMemory = ( readonly: false, }, () => { - const database = new DatabaseSync(":memory:", { + const database = new NodeSqlite.DatabaseSync(":memory:", { allowExtension: config.allowExtension ?? false, }); return database; @@ -250,26 +309,15 @@ const makeMemory = ( export const layerConfig = ( config: Config.Wrap, -): Layer.Layer => - Layer.effectContext( - Config.unwrap(config).pipe( - Effect.flatMap(make), - Effect.map((client) => - Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), - ), - ), - ).pipe(Layer.provide(Reactivity.layer)); +): Layer.Layer => + Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe( + Layer.provide(Reactivity.layer), + ); -export const layer = (config: SqliteClientConfig): Layer.Layer => - Layer.effectContext( - Effect.map(make(config), (client) => - Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), - ), - ).pipe(Layer.provide(Reactivity.layer)); +export const layer = (config: SqliteClientConfig): Layer.Layer => + Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)); -export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer => - Layer.effectContext( - Effect.map(makeMemory(config), (client) => - Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), - ), - ).pipe(Layer.provide(Reactivity.layer)); +export const layerMemory = ( + config: SqliteMemoryClientConfig = {}, +): Layer.Layer => + Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer)); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts new file mode 100644 index 00000000000..a3475d2f190 --- /dev/null +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -0,0 +1,319 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + IsoDateTime, + ProviderInstanceId, + ProviderSessionRuntimeStatus, + RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; + +import { + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, + type ProviderSessionRuntimeRepositoryError, +} from "./Errors.ts"; + +/** + * ProviderSessionRuntimeRepository - Repository interface for provider runtime sessions. + * + * Owns persistence operations for provider runtime metadata and resume cursors. + * + * @module ProviderSessionRuntimeRepository + */ + +export const ProviderSessionRuntime = Schema.Struct({ + threadId: ThreadId, + providerName: Schema.String, + /** + * User-defined routing key for the configured provider instance that + * owns this session. Nullable only at the storage/migration boundary: + * rows persisted before the driver/instance split carry only + * `providerName`. Repository consumers must materialize a concrete + * instance id before routing. + */ + providerInstanceId: Schema.NullOr(ProviderInstanceId), + adapterKey: Schema.String, + runtimeMode: RuntimeMode, + status: ProviderSessionRuntimeStatus, + lastSeenAt: IsoDateTime, + resumeCursor: Schema.NullOr(Schema.Unknown), + runtimePayload: Schema.NullOr(Schema.Unknown), +}); +export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; + +export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); +export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; + +export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); +export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; + +/** + * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. + */ +export class ProviderSessionRuntimeRepository extends Context.Service< + ProviderSessionRuntimeRepository, + { + /** + * Insert or replace a provider runtime row. + * + * Upserts by canonical `threadId`, including JSON payload/cursor fields. + */ + readonly upsert: ( + runtime: ProviderSessionRuntime, + ) => Effect.Effect; + + /** + * Read provider runtime state by canonical thread id. + */ + readonly getByThreadId: ( + input: GetProviderSessionRuntimeInput, + ) => Effect.Effect< + Option.Option, + ProviderSessionRuntimeRepositoryError + >; + + /** + * List all provider runtime rows. + * + * Returned in ascending last-seen order. + */ + readonly list: () => Effect.Effect< + ReadonlyArray, + ProviderSessionRuntimeRepositoryError + >; + + /** + * Delete provider runtime state by canonical thread id. + */ + readonly deleteByThreadId: ( + input: DeleteProviderSessionRuntimeInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProviderSessionRuntime/ProviderSessionRuntimeRepository") {} + +const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( + Struct.assign({ + resumeCursor: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + runtimePayload: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + }), +); + +const ProviderSessionRuntimeRawDbRowSchema = Schema.Struct({ + threadId: Schema.String, + providerName: Schema.Unknown, + providerInstanceId: Schema.Unknown, + adapterKey: Schema.Unknown, + runtimeMode: Schema.Unknown, + status: Schema.Unknown, + lastSeenAt: Schema.Unknown, + resumeCursor: Schema.Unknown, + runtimePayload: Schema.Unknown, +}); + +const decodeRuntimeRow = Schema.decodeUnknownEffect(ProviderSessionRuntimeDbRowSchema); + +const GetRuntimeRequestSchema = Schema.Struct({ + threadId: ThreadId, +}); + +const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; + +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { + return (cause: unknown): ProviderSessionRuntimeRepositoryError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRuntimeRow = SqlSchema.void({ + Request: ProviderSessionRuntimeDbRowSchema, + execute: (runtime) => + sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${runtime.threadId}, + ${runtime.providerName}, + ${runtime.providerInstanceId}, + ${runtime.adapterKey}, + ${runtime.runtimeMode}, + ${runtime.status}, + ${runtime.lastSeenAt}, + ${runtime.resumeCursor}, + ${runtime.runtimePayload} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + provider_name = excluded.provider_name, + provider_instance_id = excluded.provider_instance_id, + adapter_key = excluded.adapter_key, + runtime_mode = excluded.runtime_mode, + status = excluded.status, + last_seen_at = excluded.last_seen_at, + resume_cursor_json = excluded.resume_cursor_json, + runtime_payload_json = excluded.runtime_payload_json + `, + }); + + const getRuntimeRowByThreadId = SqlSchema.findOneOption({ + Request: GetRuntimeRequestSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", + adapter_key AS "adapterKey", + runtime_mode AS "runtimeMode", + status, + last_seen_at AS "lastSeenAt", + resume_cursor_json AS "resumeCursor", + runtime_payload_json AS "runtimePayload" + FROM provider_session_runtime + WHERE thread_id = ${threadId} + `, + }); + + const listRuntimeRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProviderSessionRuntimeRawDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", + adapter_key AS "adapterKey", + runtime_mode AS "runtimeMode", + status, + last_seen_at AS "lastSeenAt", + resume_cursor_json AS "resumeCursor", + runtime_payload_json AS "runtimePayload" + FROM provider_session_runtime + ORDER BY last_seen_at ASC, thread_id ASC + `, + }); + + const deleteRuntimeByThreadId = SqlSchema.void({ + Request: DeleteRuntimeRequestSchema, + execute: ({ threadId }) => + sql` + DELETE FROM provider_session_runtime + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => + upsertRuntimeRow(runtime).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.upsert:query", + "ProviderSessionRuntimeRepository.upsert:encodeRequest", + { threadId: runtime.threadId }, + ), + ), + ); + + const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => + getRuntimeRowByThreadId(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.getByThreadId:query", + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + { threadId: input.threadId }, + ), + ), + Effect.flatMap((runtimeRowOption) => + Option.match(runtimeRowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + cause, + { threadId: input.threadId }, + ), + ), + Effect.map((runtime) => Option.some(runtime)), + ), + }), + ), + ); + + const list: ProviderSessionRuntimeRepository["Service"]["list"] = () => + listRuntimeRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.list:query", + "ProviderSessionRuntimeRepository.list:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, + ), + ), + ), + ), + ), + ); + + const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteRuntimeByThreadId(input).pipe( + Effect.mapError( + (cause) => + new PersistenceSqlError({ + operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query", + correlation: { threadId: input.threadId }, + cause, + }), + ), + ); + + return { + upsert, + getByThreadId, + list, + deleteByThreadId, + } satisfies ProviderSessionRuntimeRepository["Service"]; +}); + +export const layer = Layer.effect(ProviderSessionRuntimeRepository, make); diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts new file mode 100644 index 00000000000..f7425200fd1 --- /dev/null +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -0,0 +1,253 @@ +import { AuthSessionId, ThreadId, type AuthEnvironmentScope } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as AuthPairingLinks from "./AuthPairingLinks.ts"; +import * as AuthSessions from "./AuthSessions.ts"; +import * as PersistenceErrors from "./Errors.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts"; + +const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); +const expiresAt = DateTime.makeUnsafe("2027-06-20T00:00:00.000Z"); +const now = DateTime.makeUnsafe("2026-06-21T00:00:00.000Z"); +const scopes: ReadonlyArray = ["access:read"]; + +const authSessionLayer = AuthSessions.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const authPairingLinkLayer = AuthPairingLinks.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); +const providerSessionRuntimeLayer = ProviderSessionRuntime.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); + +describe("persistence error correlation", () => { + it.effect("correlates auth session SQL and row-decode failures without sensitive fields", () => + Effect.gen(function* () { + const sessions = yield* AuthSessions.AuthSessionRepository; + const sql = yield* SqlClient.SqlClient; + const sessionId = AuthSessionId.make("session-correlation"); + const currentSessionId = AuthSessionId.make("current-session-correlation"); + const subject = "session-subject-secret-sentinel"; + + yield* sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }); + yield* sql` + UPDATE auth_sessions + SET scopes = ${"session-scopes-secret-sentinel"} + WHERE session_id = ${sessionId} + `; + + const decodeError = yield* Effect.flip(sessions.listActive({ now })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { sessionId }); + assert.equal( + decodeError.message, + `Decode error in AuthSessionRepository.listActive:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, "session-scopes-secret-sentinel"); + assert.notInclude(decodeError.message, subject); + + yield* sql`DROP TABLE auth_sessions`; + const createError = yield* Effect.flip( + sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { sessionId }); + assert.equal(createError.message, "SQL error in AuthSessionRepository.create:query"); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeOtherError = yield* Effect.flip( + sessions.revokeAllExcept({ currentSessionId, revokedAt: now }), + ); + assert.instanceOf(revokeOtherError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeOtherError.correlation, { currentSessionId }); + assert.equal( + revokeOtherError.message, + "SQL error in AuthSessionRepository.revokeAllExcept:query", + ); + assert.notInclude(revokeOtherError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authSessionLayer)), + ); + + it.effect("correlates pairing-link create and revoke failures by id only", () => + Effect.gen(function* () { + const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; + const sql = yield* SqlClient.SqlClient; + const id = "pairing-link-correlation"; + const credential = "pairing-credential-secret-sentinel"; + const subject = "pairing-subject-secret-sentinel"; + const scopesPayload = "pairing-scopes-secret-sentinel"; + + yield* sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + scopes, + subject, + label, + proof_key_thumbprint, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${id}, + ${credential}, + ${"one-time-token"}, + ${scopesPayload}, + ${subject}, + NULL, + NULL, + ${DateTime.formatIso(issuedAt)}, + ${DateTime.formatIso(expiresAt)}, + NULL, + NULL + ) + `; + + const decodeError = yield* Effect.flip(pairingLinks.getByCredential({ credential })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { pairingLinkId: id }); + assert.equal( + decodeError.message, + `Decode error in AuthPairingLinkRepository.getByCredential:decodeRow: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, credential); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, scopesPayload); + assert.notInclude(decodeError.message, DateTime.formatIso(issuedAt)); + + yield* sql`DROP TABLE auth_pairing_links`; + const createError = yield* Effect.flip( + pairingLinks.create({ + id, + credential, + method: "one-time-token", + scopes, + subject, + label: null, + proofKeyThumbprint: null, + createdAt: issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { pairingLinkId: id }); + assert.notInclude(createError.message, credential); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeError = yield* Effect.flip(pairingLinks.revoke({ id, revokedAt: now })); + assert.instanceOf(revokeError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeError.correlation, { pairingLinkId: id }); + assert.notInclude(revokeError.message, credential); + assert.notInclude(revokeError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authPairingLinkLayer)), + ); + + it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + Effect.gen(function* () { + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-correlation"); + const runtimePayload = "runtime-payload-secret-sentinel"; + const lastSeenAt = "2026-06-20T00:00:00.000Z"; + + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${threadId}, + ${"codex"}, + NULL, + ${"codex"}, + ${"invalid-runtime-mode"}, + ${"running"}, + ${lastSeenAt}, + NULL, + ${`{"secret":"${runtimePayload}"}`} + ) + `; + + const decodeError = yield* Effect.flip(runtimes.list()); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { threadId }); + assert.equal( + decodeError.message, + `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, runtimePayload); + assert.notInclude(decodeError.message, runtimePayload); + assert.notInclude(decodeError.message, lastSeenAt); + + yield* sql`DROP TABLE provider_session_runtime`; + const sqlFailure = yield* Effect.flip( + runtimes.upsert({ + threadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: { secret: runtimePayload }, + }), + ); + assert.instanceOf(sqlFailure, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(sqlFailure.correlation, { threadId }); + assert.equal( + sqlFailure.message, + "SQL error in ProviderSessionRuntimeRepository.upsert:query", + ); + assert.notInclude(sqlFailure.message, runtimePayload); + assert.notInclude(sqlFailure.message, lastSeenAt); + }).pipe(Effect.provide(providerSessionRuntimeLayer)), + ); +}); diff --git a/apps/server/src/persistence/RuntimeSqliteLayer.ts b/apps/server/src/persistence/RuntimeSqliteLayer.ts index 1833dcf18b9..4e77b906416 100644 --- a/apps/server/src/persistence/RuntimeSqliteLayer.ts +++ b/apps/server/src/persistence/RuntimeSqliteLayer.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; type RuntimeSqliteLayerConfig = { readonly filename: string; @@ -8,7 +9,7 @@ type RuntimeSqliteLayerConfig = { }; type Loader = { - layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; + layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; }; const defaultSqliteClientLoaders = { diff --git a/apps/server/src/persistence/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts deleted file mode 100644 index c8745982d29..00000000000 --- a/apps/server/src/persistence/Services/AuthPairingLinks.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import { AuthEnvironmentScopes } from "@t3tools/contracts"; - -import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; - -export const AuthPairingLinkRecord = Schema.Struct({ - id: Schema.String, - credential: Schema.String, - method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), - scopes: Schema.fromJsonString(AuthEnvironmentScopes), - subject: Schema.String, - label: Schema.NullOr(Schema.String), - proofKeyThumbprint: Schema.NullOr(Schema.String), - createdAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, - consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), - revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), -}); -export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; - -export const CreateAuthPairingLinkInput = Schema.Struct({ - id: Schema.String, - credential: Schema.String, - method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), - scopes: AuthEnvironmentScopes, - subject: Schema.String, - label: Schema.NullOr(Schema.String), - proofKeyThumbprint: Schema.NullOr(Schema.String), - createdAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, -}); -export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; - -export const ConsumeAuthPairingLinkInput = Schema.Struct({ - credential: Schema.String, - proofKeyThumbprint: Schema.NullOr(Schema.String), - consumedAt: Schema.DateTimeUtcFromString, - now: Schema.DateTimeUtcFromString, -}); -export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; - -export const ListActiveAuthPairingLinksInput = Schema.Struct({ - now: Schema.DateTimeUtcFromString, -}); -export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; - -export const RevokeAuthPairingLinkInput = Schema.Struct({ - id: Schema.String, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; - -export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ - credential: Schema.String, -}); -export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; - -export interface AuthPairingLinkRepositoryShape { - readonly create: ( - input: CreateAuthPairingLinkInput, - ) => Effect.Effect; - readonly consumeAvailable: ( - input: ConsumeAuthPairingLinkInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; - readonly listActive: ( - input: ListActiveAuthPairingLinksInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; - readonly revoke: ( - input: RevokeAuthPairingLinkInput, - ) => Effect.Effect; - readonly getByCredential: ( - input: GetAuthPairingLinkByCredentialInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; -} - -export class AuthPairingLinkRepository extends Context.Service< - AuthPairingLinkRepository, - AuthPairingLinkRepositoryShape ->()("t3/persistence/Services/AuthPairingLinks/AuthPairingLinkRepository") {} diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts deleted file mode 100644 index c08956bdd71..00000000000 --- a/apps/server/src/persistence/Services/AuthSessions.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - AuthClientMetadataDeviceType, - AuthEnvironmentScopes, - AuthSessionId, - ServerAuthSessionMethod, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { AuthSessionRepositoryError } from "../Errors.ts"; - -export const AuthSessionClientMetadataRecord = Schema.Struct({ - label: Schema.NullOr(Schema.String), - ipAddress: Schema.NullOr(Schema.String), - userAgent: Schema.NullOr(Schema.String), - deviceType: AuthClientMetadataDeviceType, - os: Schema.NullOr(Schema.String), - browser: Schema.NullOr(Schema.String), -}); -export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; - -export const AuthSessionRecord = Schema.Struct({ - sessionId: AuthSessionId, - subject: Schema.String, - scopes: AuthEnvironmentScopes, - method: ServerAuthSessionMethod, - client: AuthSessionClientMetadataRecord, - issuedAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, - lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), - revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), -}); -export type AuthSessionRecord = typeof AuthSessionRecord.Type; - -export const CreateAuthSessionInput = Schema.Struct({ - sessionId: AuthSessionId, - subject: Schema.String, - scopes: AuthEnvironmentScopes, - method: ServerAuthSessionMethod, - client: AuthSessionClientMetadataRecord, - issuedAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, -}); -export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; - -export const GetAuthSessionByIdInput = Schema.Struct({ - sessionId: AuthSessionId, -}); -export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; - -export const ListActiveAuthSessionsInput = Schema.Struct({ - now: Schema.DateTimeUtcFromString, -}); -export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; - -export const RevokeAuthSessionInput = Schema.Struct({ - sessionId: AuthSessionId, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; - -export const RevokeOtherAuthSessionsInput = Schema.Struct({ - currentSessionId: AuthSessionId, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; - -export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ - sessionId: AuthSessionId, - lastConnectedAt: Schema.DateTimeUtcFromString, -}); -export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; - -export interface AuthSessionRepositoryShape { - readonly create: ( - input: CreateAuthSessionInput, - ) => Effect.Effect; - readonly getById: ( - input: GetAuthSessionByIdInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly listActive: ( - input: ListActiveAuthSessionsInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly revoke: ( - input: RevokeAuthSessionInput, - ) => Effect.Effect; - readonly revokeAllExcept: ( - input: RevokeOtherAuthSessionsInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly setLastConnectedAt: ( - input: SetAuthSessionLastConnectedAtInput, - ) => Effect.Effect; -} - -export class AuthSessionRepository extends Context.Service< - AuthSessionRepository, - AuthSessionRepositoryShape ->()("t3/persistence/Services/AuthSessions/AuthSessionRepository") {} diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts deleted file mode 100644 index 125f4fa5bbf..00000000000 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * ProviderSessionRuntimeRepository - Repository interface for provider runtime sessions. - * - * Owns persistence operations for provider runtime metadata and resume cursors. - * - * @module ProviderSessionRuntimeRepository - */ -import { - IsoDateTime, - ProviderInstanceId, - ProviderSessionRuntimeStatus, - RuntimeMode, - ThreadId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; - -export const ProviderSessionRuntime = Schema.Struct({ - threadId: ThreadId, - providerName: Schema.String, - /** - * User-defined routing key for the configured provider instance that - * owns this session. Nullable only at the storage/migration boundary: - * rows persisted before the driver/instance split carry only - * `providerName`. Repository consumers must materialize a concrete - * instance id before routing. - */ - providerInstanceId: Schema.NullOr(ProviderInstanceId), - adapterKey: Schema.String, - runtimeMode: RuntimeMode, - status: ProviderSessionRuntimeStatus, - lastSeenAt: IsoDateTime, - resumeCursor: Schema.NullOr(Schema.Unknown), - runtimePayload: Schema.NullOr(Schema.Unknown), -}); -export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; - -export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); -export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; - -export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); -export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; - -/** - * ProviderSessionRuntimeRepositoryShape - Service API for provider runtime records. - */ -export interface ProviderSessionRuntimeRepositoryShape { - /** - * Insert or replace a provider runtime row. - * - * Upserts by canonical `threadId`, including JSON payload/cursor fields. - */ - readonly upsert: ( - runtime: ProviderSessionRuntime, - ) => Effect.Effect; - - /** - * Read provider runtime state by canonical thread id. - */ - readonly getByThreadId: ( - input: GetProviderSessionRuntimeInput, - ) => Effect.Effect, ProviderSessionRuntimeRepositoryError>; - - /** - * List all provider runtime rows. - * - * Returned in ascending last-seen order. - */ - readonly list: () => Effect.Effect< - ReadonlyArray, - ProviderSessionRuntimeRepositoryError - >; - - /** - * Delete provider runtime state by canonical thread id. - */ - readonly deleteByThreadId: ( - input: DeleteProviderSessionRuntimeInput, - ) => Effect.Effect; -} - -/** - * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. - */ -export class ProviderSessionRuntimeRepository extends Context.Service< - ProviderSessionRuntimeRepository, - ProviderSessionRuntimeRepositoryShape ->()("t3/persistence/Services/ProviderSessionRuntime/ProviderSessionRuntimeRepository") {} diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index a910e27470d..acdfe54301e 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -1,5 +1,6 @@ import { it } from "@effect/vitest"; import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; +import { PreviewUrlNormalizationError } from "@t3tools/shared/preview"; import { Effect, PubSub } from "effect"; import { expect } from "vite-plus/test"; @@ -83,6 +84,31 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { const manager = yield* PreviewManager.PreviewManager; const error = yield* Effect.flip(manager.open({ threadId, url: " " })); expect(error._tag).toBe("PreviewInvalidUrlError"); + expect(error).toMatchObject({ inputLength: 3, reason: "empty" }); + expect(error).not.toHaveProperty("rawUrl"); + expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); + expect((error.cause as PreviewUrlNormalizationError).reason).toBe("empty"); + }), + ); + + it.effect("preserves URL parser failures as the invalid URL cause chain", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; + const error = yield* Effect.flip(manager.open({ threadId, url: rawUrl })); + + expect(error).toMatchObject({ + inputLength: rawUrl.length, + reason: "parse", + protocol: "https:", + }); + expect(error).not.toHaveProperty("rawUrl"); + expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); + const normalizationError = error.cause as PreviewUrlNormalizationError; + expect(normalizationError.cause).toBeInstanceOf(Error); + expect(error.message).not.toContain((normalizationError.cause as Error).message); + expect(error.message).not.toMatch(/user|password|access_token|secret|fragment/); }), ); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 8fa3a3668bf..fe3557c157f 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -24,37 +24,34 @@ import { type PreviewSessionSnapshot, } from "@t3tools/contracts"; import { + isPreviewUrlNormalizationError, newPreviewTabId, normalizePreviewUrl, - PreviewUrlNormalizationError, } from "@t3tools/shared/preview"; -import { - Context, - DateTime, - Effect, - Layer, - PubSub, - type Scope, - Stream, - SynchronizedRef, -} from "effect"; - -export interface PreviewManagerShape { - readonly open: (input: PreviewOpenInput) => Effect.Effect; - readonly navigate: ( - input: PreviewNavigateInput, - ) => Effect.Effect; - readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; - readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; - readonly close: (input: PreviewCloseInput) => Effect.Effect; - readonly list: (input: PreviewListInput) => Effect.Effect; - readonly events: Stream.Stream; - readonly subscribeEvents: Effect.Effect, never, Scope.Scope>; -} +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; -export class PreviewManager extends Context.Service()( - "t3/preview/Manager/PreviewManager", -) {} +export class PreviewManager extends Context.Service< + PreviewManager, + { + readonly open: (input: PreviewOpenInput) => Effect.Effect; + readonly navigate: ( + input: PreviewNavigateInput, + ) => Effect.Effect; + readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; + readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; + readonly close: (input: PreviewCloseInput) => Effect.Effect; + readonly list: (input: PreviewListInput) => Effect.Effect; + readonly events: Stream.Stream; + readonly subscribeEvents: Effect.Effect, never, Scope.Scope>; + } +>()("t3/preview/Manager/PreviewManager") {} interface PreviewSessionState { readonly threadId: string; @@ -85,16 +82,22 @@ const sessionsForThread = ( const normalizeUrl = (rawUrl: string): Effect.Effect => Effect.try({ try: () => normalizePreviewUrl(rawUrl), - catch: (cause) => - new PreviewInvalidUrlError({ - rawUrl, - detail: - cause instanceof PreviewUrlNormalizationError - ? cause.detail - : cause instanceof Error - ? cause.message - : String(cause), - }), + catch: (cause) => { + if (isPreviewUrlNormalizationError(cause)) { + return new PreviewInvalidUrlError({ + inputLength: cause.inputLength, + reason: cause.reason, + protocol: cause.protocol, + cause, + }); + } + + return new PreviewInvalidUrlError({ + inputLength: rawUrl.length, + reason: "unexpected", + cause, + }); + }, }); const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); @@ -127,7 +130,7 @@ const buildIdleSnapshot = (input: { updatedAt: input.updatedAt, }); -const make = Effect.gen(function* PreviewManagerMake() { +export const make = Effect.gen(function* PreviewManagerMake() { const stateRef = yield* SynchronizedRef.make(initialState); // Unbounded PubSub is fine here — events are tiny and we don't want to // block publishers if a subscriber is slow. WS clients backpressure on @@ -184,38 +187,40 @@ const make = Effect.gen(function* PreviewManagerMake() { ); }; - const open: PreviewManagerShape["open"] = Effect.fn("PreviewManager.open")(function* (input) { - const tabId = newPreviewTabId(); - const updatedAt = yield* currentIsoTimestamp; - const snapshot = input.url - ? buildLoadingSnapshot({ + const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")( + function* (input) { + const tabId = newPreviewTabId(); + const updatedAt = yield* currentIsoTimestamp; + const snapshot = input.url + ? buildLoadingSnapshot({ + threadId: input.threadId, + tabId, + url: yield* normalizeUrl(input.url), + title: "", + updatedAt, + }) + : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); + yield* SynchronizedRef.update(stateRef, (state) => { + const sessions = new Map(state.sessions); + sessions.set(compositeKey(input.threadId, tabId), { threadId: input.threadId, tabId, - url: yield* normalizeUrl(input.url), - title: "", - updatedAt, - }) - : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); - yield* SynchronizedRef.update(stateRef, (state) => { - const sessions = new Map(state.sessions); - sessions.set(compositeKey(input.threadId, tabId), { + snapshot, + }); + return { sessions }; + }); + yield* PubSub.publish(eventsPubSub, { + type: "opened", threadId: input.threadId, tabId, + createdAt: snapshot.updatedAt, snapshot, }); - return { sessions }; - }); - yield* PubSub.publish(eventsPubSub, { - type: "opened", - threadId: input.threadId, - tabId, - createdAt: snapshot.updatedAt, - snapshot, - }); - return snapshot; - }); + return snapshot; + }, + ); - const navigate: PreviewManagerShape["navigate"] = Effect.fn("PreviewManager.navigate")( + const navigate: PreviewManager["Service"]["navigate"] = Effect.fn("PreviewManager.navigate")( function* (input) { const url = yield* normalizeUrl(input.url); return yield* mutateExistingSession( @@ -250,7 +255,7 @@ const make = Effect.gen(function* PreviewManagerMake() { }, ); - const reportStatus: PreviewManagerShape["reportStatus"] = Effect.fn( + const reportStatus: PreviewManager["Service"]["reportStatus"] = Effect.fn( "PreviewManager.reportStatus", )(function* (input) { yield* mutateExistingSession( @@ -294,7 +299,7 @@ const make = Effect.gen(function* PreviewManagerMake() { ); }); - const refresh: PreviewManagerShape["refresh"] = Effect.fn("PreviewManager.refresh")( + const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")( function* (input) { // Verify the session exists; the desktop bridge handles the actual reload // and will report progress back via `reportStatus`. No event emitted. @@ -304,50 +309,54 @@ const make = Effect.gen(function* PreviewManagerMake() { }, ); - const close: PreviewManagerShape["close"] = Effect.fn("PreviewManager.close")(function* (input) { - const createdAt = yield* currentIsoTimestamp; - const events = yield* SynchronizedRef.modify(stateRef, (state) => { - const eventsToEmit: PreviewEvent[] = []; - const sessions = new Map(state.sessions); - const targets = input.tabId - ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( - (entry): entry is PreviewSessionState => entry !== undefined, - ) - : sessionsForThread(state, input.threadId); - for (const target of targets) { - sessions.delete(compositeKey(target.threadId, target.tabId)); - eventsToEmit.push({ - type: "closed", - threadId: target.threadId, - tabId: target.tabId, - createdAt, + const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")( + function* (input) { + const createdAt = yield* currentIsoTimestamp; + const events = yield* SynchronizedRef.modify(stateRef, (state) => { + const eventsToEmit: PreviewEvent[] = []; + const sessions = new Map(state.sessions); + const targets = input.tabId + ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( + (entry): entry is PreviewSessionState => entry !== undefined, + ) + : sessionsForThread(state, input.threadId); + for (const target of targets) { + sessions.delete(compositeKey(target.threadId, target.tabId)); + eventsToEmit.push({ + type: "closed", + threadId: target.threadId, + tabId: target.tabId, + createdAt, + }); + } + if (eventsToEmit.length === 0) { + return [eventsToEmit, state] as const; + } + return [eventsToEmit, { sessions }] as const; + }); + if (events.length > 0) { + yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { + discard: true, }); } - if (eventsToEmit.length === 0) { - return [eventsToEmit, state] as const; - } - return [eventsToEmit, { sessions }] as const; - }); - if (events.length > 0) { - yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { - discard: true, - }); - } - }); + }, + ); - const list: PreviewManagerShape["list"] = Effect.fn("PreviewManager.list")(function* (input) { - return yield* SynchronizedRef.get(stateRef).pipe( - Effect.map( - (state): PreviewListResult => ({ - sessions: sessionsForThread(state, input.threadId) - .map((s) => s.snapshot) - .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), - }), - ), - ); - }); + const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")( + function* (input) { + return yield* SynchronizedRef.get(stateRef).pipe( + Effect.map( + (state): PreviewListResult => ({ + sessions: sessionsForThread(state, input.threadId) + .map((s) => s.snapshot) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), + }), + ), + ); + }, + ); - return { + return PreviewManager.of({ open, navigate, reportStatus, @@ -356,7 +365,7 @@ const make = Effect.gen(function* PreviewManagerMake() { list, events, subscribeEvents: PubSub.subscribe(eventsPubSub), - } satisfies PreviewManagerShape; + }); }).pipe(Effect.withSpan("PreviewManager.make")); export const layer = Layer.effect(PreviewManager, make); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 481d28d782f..69b5729164d 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,25 +1,59 @@ -import * as net from "node:net"; +import * as NodeNet from "node:net"; import { it as effectIt } from "@effect/vitest"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; -import { Effect, Layer } from "effect"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { expect } from "vite-plus/test"; -import { ProcessRunner } from "../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; -const TestProcessRunner = Layer.succeed(ProcessRunner, { - run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), +const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cwd: input.cwd, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "PowerShell is not installed in the test environment", + }), + }), + ), }); + +const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) => + PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProcessRunner.ProcessRunner, { run }), + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }), + Layer.succeed(HostProcessPlatform, "linux"), + ), + ), + ); + const TestPortDiscoveryLive = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), ), ); -const openServer = (port: number): Effect.Effect => +const openServer = (port: number): Effect.Effect => Effect.callback((resume) => { - const server = net.createServer(); + const server = NodeNet.createServer(); server.once("error", () => { resume(Effect.succeed(null)); }); @@ -31,7 +65,7 @@ const openServer = (port: number): Effect.Effect => }); }); -const closeServer = (server: net.Server): Effect.Effect => +const closeServer = (server: NodeNet.Server): Effect.Effect => Effect.callback((resume) => { server.close(() => resume(Effect.void)); }); @@ -87,3 +121,37 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall }), ); }); + +effectIt("does not swallow process probe defects", () => + Effect.gen(function* () { + const defect = new Error("unexpected process probe defect"); + const layer = makeProbeFailureLayer(() => Effect.die(defect)); + + const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); + } + }), +); + +effectIt("does not swallow process probe interruption", () => + Effect.gen(function* () { + const layer = makeProbeFailureLayer(() => Effect.interrupt); + + const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 183d5d4f009..c306fca2b33 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -15,30 +15,36 @@ import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; -import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; -import { ProcessRunner } from "../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; -export interface PortDiscoveryShape { - readonly scan: () => Effect.Effect>; - readonly subscribe: ( - listener: (servers: ReadonlyArray) => Effect.Effect, - ) => Effect.Effect; - readonly retain: Effect.Effect; - readonly registerTerminalProcesses: (input: { - readonly threadId: string; - readonly terminalId: string; - readonly processIds: ReadonlyArray; - }) => Effect.Effect; - readonly unregisterTerminal: (input: { - readonly threadId: string; - readonly terminalId: string; - }) => Effect.Effect; -} - -export class PortDiscovery extends Context.Service()( - "t3/preview/PortScanner/PortDiscovery", -) {} +export class PortDiscovery extends Context.Service< + PortDiscovery, + { + readonly scan: () => Effect.Effect>; + readonly subscribe: ( + listener: (servers: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect; + readonly retain: Effect.Effect; + readonly registerTerminalProcesses: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray; + }) => Effect.Effect; + readonly unregisterTerminal: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; + } +>()("t3/preview/PortScanner/PortDiscovery") {} export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, @@ -180,9 +186,9 @@ const serversEqual = ( return true; }; -const make = Effect.gen(function* PortDiscoveryMake() { +export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; - const processRunner = yield* ProcessRunner; + const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcessPlatform; const stateRef = yield* Ref.make({ lastSnapshot: [], @@ -215,6 +221,14 @@ const make = Effect.gen(function* PortDiscoveryMake() { })); }); + const recoverProcessProbeFailure = + (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => + Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + cause: error, + probe, + platform: hostPlatform, + }).pipe(Effect.as(null)); + const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map(); @@ -224,6 +238,7 @@ const make = Effect.gen(function* PortDiscoveryMake() { } } if (hostPlatform === "win32") { + const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); const command = 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; const listeners = yield* processRunner @@ -236,11 +251,18 @@ const make = Effect.gen(function* PortDiscoveryMake() { }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), - Effect.catchCause(() => Effect.succeed(null)), + Effect.catchTags({ + ProcessSpawnError: recoverWindowsProbeFailure, + ProcessStdinError: recoverWindowsProbeFailure, + ProcessOutputLimitError: recoverWindowsProbeFailure, + ProcessReadError: recoverWindowsProbeFailure, + ProcessTimeoutError: recoverWindowsProbeFailure, + }), ); if (listeners !== null) return listeners; return yield* probeCommonPorts(); } + const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); const lsofResult = yield* processRunner .run({ command: "lsof", @@ -251,7 +273,13 @@ const make = Effect.gen(function* PortDiscoveryMake() { }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), - Effect.catchCause(() => Effect.succeed(null)), + Effect.catchTags({ + ProcessSpawnError: recoverLsofProbeFailure, + ProcessStdinError: recoverLsofProbeFailure, + ProcessOutputLimitError: recoverLsofProbeFailure, + ProcessReadError: recoverLsofProbeFailure, + ProcessTimeoutError: recoverLsofProbeFailure, + }), ); if (lsofResult !== null) return lsofResult; return yield* probeCommonPorts(); @@ -296,14 +324,14 @@ const make = Effect.gen(function* PortDiscoveryMake() { } }); - const retain: PortDiscoveryShape["retain"] = Effect.acquireRelease(acquireRetention(), () => + const retain: PortDiscovery["Service"]["retain"] = Effect.acquireRelease(acquireRetention(), () => Ref.update(stateRef, (state) => ({ ...state, retainCount: Math.max(0, state.retainCount - 1), })), ); - const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")( + const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( (listener) => Effect.acquireRelease( Ref.update(stateRef, (state) => ({ @@ -319,29 +347,28 @@ const make = Effect.gen(function* PortDiscoveryMake() { ), ); - const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn( - "PortDiscovery.registerTerminalProcesses", - )(function* (input) { - const owner = { - threadId: ThreadId.make(input.threadId), - terminalId: input.terminalId, - }; - const processIds = new Set( - input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), - ); - yield* Ref.update(stateRef, (state) => { - const terminalProcesses = new Map(state.terminalProcesses); - const key = terminalOwnerKey(owner); - if (processIds.size === 0) { - terminalProcesses.delete(key); - } else { - terminalProcesses.set(key, { owner, processIds }); - } - return { ...state, terminalProcesses }; + const registerTerminalProcesses: PortDiscovery["Service"]["registerTerminalProcesses"] = + Effect.fn("PortDiscovery.registerTerminalProcesses")(function* (input) { + const owner = { + threadId: ThreadId.make(input.threadId), + terminalId: input.terminalId, + }; + const processIds = new Set( + input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), + ); + yield* Ref.update(stateRef, (state) => { + const terminalProcesses = new Map(state.terminalProcesses); + const key = terminalOwnerKey(owner); + if (processIds.size === 0) { + terminalProcesses.delete(key); + } else { + terminalProcesses.set(key, { owner, processIds }); + } + return { ...state, terminalProcesses }; + }); }); - }); - const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( + const unregisterTerminal: PortDiscovery["Service"]["unregisterTerminal"] = Effect.fn( "PortDiscovery.unregisterTerminal", )(function* (input) { yield* Ref.update(stateRef, (state) => { @@ -351,13 +378,13 @@ const make = Effect.gen(function* PortDiscoveryMake() { }); }); - return { + return PortDiscovery.of({ scan: scanOnce, subscribe, retain, registerTerminalProcesses, unregisterTerminal, - } satisfies PortDiscoveryShape; + }); }).pipe(Effect.withSpan("PortDiscovery.make")); export const layer = Layer.effect(PortDiscovery, make); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 0a157e301c4..43ca40e9c7c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -11,7 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; -import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; +import * as ExternalLauncher from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { return ChildProcessSpawner.makeHandle({ @@ -54,7 +54,7 @@ const testLayer = (input: { ); return Layer.mergeAll( - ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), + ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), Layer.succeed( SpawnExecutableResolution, @@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => { let spawned: ChildProcess.StandardCommand | undefined; let didUnref = false; return Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchBrowser("https://example.com/some path"); @@ -101,7 +101,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => let spawned: ChildProcess.StandardCommand | undefined; yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchEditor({ editor: "vscode", cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", @@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () => yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; return yield* launcher.resolveAvailableEditors(); }).pipe( Effect.provide( @@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () => it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { - const launcher = yield* ExternalLauncher; - const result = yield* launcher + const launcher = yield* ExternalLauncher.ExternalLauncher; + const error = yield* launcher .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) - .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + .pipe(Effect.flip); + assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError); + assert.equal(error.editor, "missing-editor"); + assert.equal(error.message, "Unknown editor: missing-editor"); }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 0b40acef5c0..9c2f0e417d3 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -9,6 +9,11 @@ import { EDITORS, ExternalLauncherError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherEditorSpawnError, + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; @@ -22,15 +27,26 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; // ============================== // Definitions // ============================== -export { ExternalLauncherError }; +export { + ExternalLauncherError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherEditorSpawnError, + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, + isExternalLauncherError, +} from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { + readonly editor: EditorId; + readonly target: string; readonly command: string; readonly args: ReadonlyArray; } @@ -282,30 +298,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit return yield* buildAvailableEditors(platform, env); }); -/** - * ExternalLauncherShape - Service API for browser and editor launch actions. - */ -export interface ExternalLauncherShape { - readonly resolveAvailableEditors: () => Effect.Effect>; - /** - * Launch a URL target in the default browser. - */ - readonly launchBrowser: (target: string) => Effect.Effect; - - /** - * Launch a workspace path in a selected editor integration. - * - * Launches the editor as a detached process so server startup is not blocked. - */ - readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; -} - /** * ExternalLauncher - Service tag for browser/editor launch operations. */ -export class ExternalLauncher extends Context.Service()( - "t3/process/externalLauncher", -) {} +export class ExternalLauncher extends Context.Service< + ExternalLauncher, + { + readonly resolveAvailableEditors: () => Effect.Effect>; + /** Launch a URL target in the default browser. */ + readonly launchBrowser: (target: string) => Effect.Effect; + /** + * Launch a workspace path in a selected editor integration. + * + * Launches the editor as a detached process so server startup is not blocked. + */ + readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; + } +>()("t3/process/externalLauncher") {} // ============================== // Implementations @@ -323,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }); const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { - return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); + return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor }); } if (editorDef.commands) { @@ -332,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( () => editorDef.commands[0], ); return { + editor: editorDef.id, + target: input.cwd, command, args: resolveEditorArgs(editorDef, input.cwd), }; } if (editorDef.id !== "file-manager") { - return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } - return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; + return { + editor: editorDef.id, + target: input.cwd, + command: fileManagerCommandForPlatform(platform), + args: [input.cwd], + }; }); const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, - errorMessage: string, + onError: (cause: unknown) => ExternalLauncherError, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make(launch.command, launch.args, launch.options); @@ -355,7 +371,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( Effect.flatMap((handle) => handle.unref), Effect.asVoid, Effect.scoped, - Effect.mapError((cause) => new ExternalLauncherError({ message: errorMessage, cause })), + Effect.mapError(onError), ); }); @@ -363,7 +379,16 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { const launch = yield* resolveBrowserLaunch(target); - return yield* launchAndUnref(launch, "Browser auto-open failed"); + return yield* launchAndUnref( + launch, + (cause) => + new ExternalLauncherBrowserSpawnError({ + target, + command: launch.command, + args: launch.args, + cause, + }), + ); }); const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( @@ -375,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu > { const env = yield* readCommandLookupEnv; if (!(yield* isCommandAvailable(launch.command, { env }))) { - return yield* new ExternalLauncherError({ - message: `Editor command not found: ${launch.command}`, + return yield* new ExternalLauncherCommandNotFoundError({ + editor: launch.editor, + command: launch.command, }); } @@ -393,11 +419,18 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu stderr: "ignore", }, }, - "failed to spawn detached process", + (cause) => + new ExternalLauncherEditorSpawnError({ + editor: launch.editor, + target: launch.target, + command: spawnCommand.command, + args: spawnCommand.args, + cause, + }), ); }); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -410,7 +443,7 @@ const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - return { + return ExternalLauncher.of({ resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), launchBrowser: (target) => launchBrowser(target).pipe( @@ -424,7 +457,7 @@ const make = Effect.gen(function* () { ), ), ), - } satisfies ExternalLauncherShape; + }); }); export const layer = Layer.effect(ExternalLauncher, make); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index f914c667a1c..e264ba7849d 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; @@ -11,14 +12,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; -import { - isWindowsCommandNotFound, - ProcessOutputLimitError, - ProcessRunner, - ProcessTimeoutError, - layer as ProcessRunnerLive, - type ProcessRunInput, -} from "./processRunner.ts"; +import * as ProcessRunner from "./processRunner.ts"; type ChildProcessCommand = { readonly command: string; @@ -62,21 +56,24 @@ function makeHandle(input: { } function makeSpawner( - f: (command: ChildProcessCommand) => Effect.Effect, + f: ( + command: ChildProcessCommand, + ) => Effect.Effect, ) { return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command))); } const runWith = - (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => (input: ProcessRunInput) => - Effect.service(ProcessRunner).pipe( + (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => + (input: ProcessRunner.ProcessRunInput) => + Effect.service(ProcessRunner.ProcessRunner).pipe( Effect.flatMap((runner) => runner.run({ ...input, }), ), Effect.provide( - ProcessRunnerLive.pipe( + ProcessRunner.layer.pipe( Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ), ), @@ -112,12 +109,12 @@ describe("runProcess", () => { return makeHandle({ stdout: "service ok" }); }), ); - const layer = ProcessRunnerLive.pipe( + const layer = ProcessRunner.layer.pipe( Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ); return Effect.gen(function* () { - const runner = yield* ProcessRunner; + const runner = yield* ProcessRunner.ProcessRunner; const result = yield* runner.run({ command: "fake", args: ["--service"], @@ -165,6 +162,44 @@ describe("runProcess", () => { ); }); + it.effect("preserves resolved spawn context and cause", () => + Effect.gen(function* () { + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: "/actual/fake", + }); + const spawner = makeSpawner(() => Effect.fail(cause)); + + const error = yield* runWith(spawner)({ + command: "fake", + args: ["--flag", "secret-token-value"], + cwd: "/logical", + spawnCwd: "/actual", + }).pipe(Effect.flip); + + expect(error._tag).toBe("ProcessSpawnError"); + if (error._tag !== "ProcessSpawnError") { + return expect.fail("Expected ProcessSpawnError"); + } + expect(error).toMatchObject({ + command: "fake", + argumentCount: 2, + cwd: "/logical", + spawnCwd: "/actual", + resolvedCommand: "fake", + resolvedArgumentCount: 2, + shell: false, + }); + expect(error.cause).toBe(cause); + expect(error.message).toBe("Failed to spawn process 'fake' in '/actual'"); + expect(error).not.toHaveProperty("args"); + expect(error).not.toHaveProperty("resolvedArgs"); + expect(error.message).not.toContain("secret-token-value"); + }), + ); + it.effect("fails when output exceeds max buffer in default mode", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); @@ -175,7 +210,39 @@ describe("runProcess", () => { maxOutputBytes: 128, }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessOutputLimitError); + expect(error._tag).toBe("ProcessOutputLimitError"); + if (error._tag !== "ProcessOutputLimitError") { + return expect.fail("Expected ProcessOutputLimitError"); + } + expect(error).toMatchObject({ + stream: "stdout", + maxBytes: 128, + observedBytes: 2048, + }); + expect(error.message).toBe( + "Process 'fake' stdout produced 2048 bytes, exceeding the 128 byte limit", + ); + }), + ); + + it.effect("accepts output at the byte limit followed by an empty chunk", () => + Effect.gen(function* () { + const output = new TextEncoder().encode("exactly"); + const spawner = makeSpawner(() => + Effect.succeed( + makeHandle({ + stdout: Stream.make(output, new Uint8Array()), + }), + ), + ); + + const result = yield* runWith(spawner)({ + command: "fake", + args: ["exact-limit"], + maxOutputBytes: output.byteLength, + }); + + expect(result.stdout).toBe("exactly"); }), ); @@ -200,7 +267,7 @@ describe("runProcess", () => { timeout: "2 seconds", }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessOutputLimitError); + expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError); }), ); @@ -278,6 +345,8 @@ describe("runProcess", () => { const errorFiber = yield* runWith(spawner)({ command: "fake", args: ["sleep"], + cwd: "/logical", + spawnCwd: "/actual", timeout: "50 millis", }).pipe(Effect.flip, Effect.forkScoped); @@ -285,7 +354,18 @@ describe("runProcess", () => { yield* TestClock.adjust(Duration.millis(50)); const error = yield* Fiber.join(errorFiber); - expect(error).toBeInstanceOf(ProcessTimeoutError); + expect(error._tag).toBe("ProcessTimeoutError"); + if (error._tag !== "ProcessTimeoutError") { + return expect.fail("Expected ProcessTimeoutError"); + } + expect(error).toMatchObject({ + command: "fake", + argumentCount: 1, + cwd: "/logical", + spawnCwd: "/actual", + timeoutMs: 50, + }); + expect(error.message).toBe("Process 'fake' in '/actual' timed out after 50ms"); }), ); @@ -324,7 +404,7 @@ describe("runProcess", () => { describe("isWindowsCommandNotFound", () => { it.effect("matches the localized German cmd.exe error text", () => Effect.gen(function* () { - const isCommandNotFound = yield* isWindowsCommandNotFound( + const isCommandNotFound = yield* ProcessRunner.isWindowsCommandNotFound( 1, "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", ).pipe(Effect.provideService(HostProcessPlatform, "win32")); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 4cfb764c557..c1ee2b2cb0c 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -1,13 +1,14 @@ -import * as Data from "effect/Data"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { @@ -42,57 +43,106 @@ export interface ProcessRunOutput { readonly stderrTruncated: boolean; } -export class ProcessSpawnError extends Data.TaggedError("ProcessSpawnError")<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string | undefined; - readonly cause: unknown; -}> {} +const ProcessInvocationFields = { + command: Schema.String, + argumentCount: Schema.Number, + cwd: Schema.optional(Schema.String), + spawnCwd: Schema.optional(Schema.String), +}; -export class ProcessStdinError extends Data.TaggedError("ProcessStdinError")<{ +const formatProcessInvocation = (input: { readonly command: string; - readonly args: ReadonlyArray; readonly cwd?: string | undefined; - readonly cause: unknown; -}> {} + readonly spawnCwd?: string | undefined; +}): string => { + const executionCwd = input.spawnCwd ?? input.cwd; + return executionCwd === undefined + ? `'${input.command}'` + : `'${input.command}' in '${executionCwd}'`; +}; -export class ProcessOutputLimitError extends Data.TaggedError("ProcessOutputLimitError")<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string | undefined; - readonly stream: "stdout" | "stderr"; - readonly maxBytes: number; -}> {} +export class ProcessSpawnError extends Schema.TaggedErrorClass()( + "ProcessSpawnError", + { + ...ProcessInvocationFields, + resolvedCommand: Schema.optional(Schema.String), + resolvedArgumentCount: Schema.optional(Schema.Number), + shell: Schema.optional(Schema.Boolean), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn process ${formatProcessInvocation(this)}`; + } +} -export class ProcessReadError extends Data.TaggedError("ProcessReadError")<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string | undefined; - readonly stream: "stdout" | "stderr" | "exitCode"; - readonly cause: unknown; -}> {} +export class ProcessStdinError extends Schema.TaggedErrorClass()( + "ProcessStdinError", + { + ...ProcessInvocationFields, + stdinBytes: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to write stdin for process ${formatProcessInvocation(this)}`; + } +} -export class ProcessTimeoutError extends Data.TaggedError("ProcessTimeoutError")<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string | undefined; - readonly timeoutMs: number; -}> {} +export class ProcessOutputLimitError extends Schema.TaggedErrorClass()( + "ProcessOutputLimitError", + { + ...ProcessInvocationFields, + stream: Schema.Literals(["stdout", "stderr"]), + maxBytes: Schema.Number, + observedBytes: Schema.Number, + }, +) { + override get message(): string { + return `Process ${formatProcessInvocation(this)} ${this.stream} produced ${this.observedBytes} bytes, exceeding the ${this.maxBytes} byte limit`; + } +} -export type ProcessRunError = - | ProcessSpawnError - | ProcessStdinError - | ProcessOutputLimitError - | ProcessReadError - | ProcessTimeoutError; +export class ProcessReadError extends Schema.TaggedErrorClass()( + "ProcessReadError", + { + ...ProcessInvocationFields, + stream: Schema.Literals(["stdout", "stderr", "exitCode"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.stream} for process ${formatProcessInvocation(this)}`; + } +} -export interface ProcessRunnerShape { - readonly run: (input: ProcessRunInput) => Effect.Effect; +export class ProcessTimeoutError extends Schema.TaggedErrorClass()( + "ProcessTimeoutError", + { + ...ProcessInvocationFields, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Process ${formatProcessInvocation(this)} timed out after ${this.timeoutMs}ms`; + } } -export class ProcessRunner extends Context.Service()( - "t3/processRunner", -) {} +export const ProcessRunError = Schema.Union([ + ProcessSpawnError, + ProcessStdinError, + ProcessOutputLimitError, + ProcessReadError, + ProcessTimeoutError, +]); +export type ProcessRunError = typeof ProcessRunError.Type; + +export class ProcessRunner extends Context.Service< + ProcessRunner, + { + readonly run: (input: ProcessRunInput) => Effect.Effect; + } +>()("t3/processRunner") {} const DEFAULT_TIMEOUT = "60 seconds"; const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; @@ -123,6 +173,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { readonly command: string; readonly args: ReadonlyArray; readonly cwd?: string | undefined; + readonly spawnCwd?: string | undefined; readonly streamName: "stdout" | "stderr"; readonly stream: Stream.Stream; readonly maxOutputBytes: number; @@ -134,8 +185,9 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { (cause) => new ProcessReadError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: input.streamName, cause, }), @@ -163,14 +215,16 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { () => ({ chunks: [], bytes: 0 }), (state, chunk) => { const remainingBytes = input.maxOutputBytes - state.bytes; - if (remainingBytes <= 0 || chunk.byteLength > remainingBytes) { + if (chunk.byteLength > remainingBytes) { return Effect.fail( new ProcessOutputLimitError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: input.streamName, maxBytes: input.maxOutputBytes, + observedBytes: state.bytes + chunk.byteLength, }), ); } @@ -219,8 +273,9 @@ function finalizeRunProcess( return Effect.fail( new ProcessTimeoutError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, timeoutMs: Duration.toMillis(timeout), }), ); @@ -260,23 +315,30 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( (cause) => new ProcessSpawnError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, + resolvedCommand: spawnCommand.command, + resolvedArgumentCount: spawnCommand.args.length, + shell: spawnCommand.shell, cause, }), ), ); + const stdin = input.stdin; const writeStdin = - input.stdin === undefined + stdin === undefined ? Effect.void - : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + : Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe( Effect.mapError( (cause) => new ProcessStdinError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, + stdinBytes: Buffer.byteLength(stdin), cause, }), ), @@ -288,6 +350,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( command: input.command, args: input.args, cwd: input.cwd, + spawnCwd: input.spawnCwd, streamName: "stdout", stream: child.stdout, maxOutputBytes, @@ -298,6 +361,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( command: input.command, args: input.args, cwd: input.cwd, + spawnCwd: input.spawnCwd, streamName: "stderr", stream: child.stderr, maxOutputBytes, @@ -314,8 +378,9 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( (cause) => new ProcessReadError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: "exitCode", cause, }), @@ -332,10 +397,10 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } satisfies ProcessRunOutput; }); -export const make = Effect.fn("makeProcessRunner")(function* () { +export const make = Effect.fn("ProcessRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const run: ProcessRunnerShape["run"] = (input) => + const run: ProcessRunner["Service"]["run"] = (input) => finalizeRunProcess(runProcessCore(spawner, input), input); return ProcessRunner.of({ diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts deleted file mode 100644 index 5c0e5d95742..00000000000 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it, describe, expect } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; -import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; -import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; - -const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))), - Layer.provideMerge(NodeServices.layer), -); - -const makeTempDir = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-project-favicon-", - }); -}); - -const writeTextFile = Effect.fn("writeTextFile")(function* ( - cwd: string, - relativePath: string, - contents: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const absolutePath = path.join(cwd, relativePath); - yield* fileSystem - .makeDirectory(path.dirname(absolutePath), { recursive: true }) - .pipe(Effect.orDie); - yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); -}); - -it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { - describe("resolvePath", () => { - it.effect("prefers well-known favicon files", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "favicon.svg", "favicon"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toContain("favicon.svg"); - }), - ); - - it.effect("resolves icon hrefs from project source files", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "index.html", ''); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); - }), - ); - - it.effect("returns null when no icon is present", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).toBeNull(); - }), - ); - }); -}); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts deleted file mode 100644 index a994d1a7e8c..00000000000 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ /dev/null @@ -1,149 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { - ProjectFaviconResolver, - type ProjectFaviconResolverShape, -} from "../Services/ProjectFaviconResolver.ts"; -import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts"; - -// Well-known favicon paths checked in order. -const FAVICON_CANDIDATES = [ - "favicon.svg", - "favicon.ico", - "favicon.png", - "public/favicon.svg", - "public/favicon.ico", - "public/favicon.png", - "app/favicon.ico", - "app/favicon.png", - "app/icon.svg", - "app/icon.png", - "app/icon.ico", - "src/favicon.ico", - "src/favicon.svg", - "src/app/favicon.ico", - "src/app/icon.svg", - "src/app/icon.png", - "assets/icon.svg", - "assets/icon.png", - "assets/logo.svg", - "assets/logo.png", - ".idea/icon.svg", -] as const; - -// Files that may contain a or icon metadata declaration. -const ICON_SOURCE_FILES = [ - "index.html", - "public/index.html", - "app/routes/__root.tsx", - "src/routes/__root.tsx", - "app/root.tsx", - "src/root.tsx", - "src/index.html", -] as const; - -// Matches tags or object-like icon metadata where rel/href can appear in any order. -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; -const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; - -function extractIconHref(source: string): string | null { - const htmlMatch = source.match(LINK_ICON_HTML_RE); - if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; - return null; -} - -export const makeProjectFaviconResolver = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; - - const resolveIconHref = (href: string): string[] => { - const clean = href.replace(/^\//, ""); - return [path.join("public", clean), clean]; - }; - - const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( - projectCwd: string, - relativeCandidates: ReadonlyArray, - ): Effect.fn.Return { - for (const relativePath of relativeCandidates) { - const candidate = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath, - }) - .pipe(Effect.orElseSucceed(() => null)); - if (!candidate) { - continue; - } - const stats = yield* fileSystem - .stat(candidate.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (stats?.type === "File") { - return candidate.absolutePath; - } - } - return null; - }); - - const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", - )(function* (cwd: string): Effect.fn.Return { - const projectCwd = yield* workspacePaths - .normalizeWorkspaceRoot(cwd) - .pipe(Effect.orElseSucceed(() => null)); - if (!projectCwd) { - return null; - } - for (const candidate of FAVICON_CANDIDATES) { - const existing = yield* findExistingFile(projectCwd, [candidate]); - if (existing) { - return existing; - } - } - - for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath: sourceFile, - }) - .pipe(Effect.orElseSucceed(() => null)); - if (!sourcePath) { - continue; - } - const source = yield* fileSystem - .readFileString(sourcePath.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (!source) { - continue; - } - const href = extractIconHref(source); - if (!href) { - continue; - } - const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); - if (existing) { - return existing; - } - } - - return null; - }); - - return { - resolvePath, - } satisfies ProjectFaviconResolverShape; -}); - -export const ProjectFaviconResolverLive = Layer.effect( - ProjectFaviconResolver, - makeProjectFaviconResolver, -); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts deleted file mode 100644 index 747ac98daeb..00000000000 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { ProjectId, type OrchestrationProject } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { LaunchEnvTestLayer } from "../../launchEnv/Layers/LaunchEnvTest.ts"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; -import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; -import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; - -const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/repo/project", - defaultModelSelection: null, - scripts, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - deletedAt: null, -}); - -const TEST_BASE_DIR = "/tmp/t3-setup-script-runner"; -const launchEnvLayer = LaunchEnvTestLayer.stub({ - t3Home: TEST_BASE_DIR, - projectId: ProjectId.make("project-1"), -}); - -const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => - Layer.mock(ProjectionSnapshotQuery)({ - getActiveProjectByWorkspaceRoot: (workspaceRoot) => - Effect.succeed( - workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), - ), - getProjectShellById: (projectId) => - Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), - }); - -describe("ProjectSetupScriptRunner", () => { - it("returns no-script when no setup script exists", async () => { - const open = vi.fn(); - const write = vi.fn(); - const project = makeProject([]); - const runner = await Effect.runPromise( - Effect.service(ProjectSetupScriptRunner).pipe( - Effect.provide( - ProjectSetupScriptRunnerLive.pipe( - Layer.provideMerge(launchEnvLayer), - Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), - Layer.provideMerge( - Layer.succeed(TerminalManager, { - open, - attachStream: () => Effect.die(new Error("unused")), - write, - resize: () => Effect.void, - clear: () => Effect.void, - restart: () => Effect.die(new Error("unused")), - close: () => Effect.void, - subscribe: () => Effect.succeed(() => undefined), - subscribeMetadata: () => Effect.succeed(() => undefined), - }), - ), - ), - ), - ), - ); - - const result = await Effect.runPromise( - runner.runForThread({ - threadId: "thread-1", - projectId: "project-1", - worktreePath: "/repo/worktrees/a", - }), - ); - - expect(result).toEqual({ status: "no-script" }); - expect(open).not.toHaveBeenCalled(); - expect(write).not.toHaveBeenCalled(); - }); - - it("opens the deterministic setup terminal with worktree env and writes the command", async () => { - const open = vi.fn(() => - Effect.succeed({ - threadId: "thread-1", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - worktreePath: "/repo/worktrees/a", - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "setup-setup", - updatedAt: "2026-01-01T00:00:00.000Z", - }), - ); - const write = vi.fn(() => Effect.void); - const project = makeProject([ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ]); - const runner = await Effect.runPromise( - Effect.service(ProjectSetupScriptRunner).pipe( - Effect.provide( - ProjectSetupScriptRunnerLive.pipe( - Layer.provideMerge(launchEnvLayer), - Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), - Layer.provideMerge( - Layer.succeed(TerminalManager, { - open, - attachStream: () => Effect.die(new Error("unused")), - write, - resize: () => Effect.void, - clear: () => Effect.void, - restart: () => Effect.die(new Error("unused")), - close: () => Effect.void, - subscribe: () => Effect.succeed(() => undefined), - subscribeMetadata: () => Effect.succeed(() => undefined), - }), - ), - ), - ), - ), - ); - - const result = await Effect.runPromise( - runner.runForThread({ - threadId: "thread-1", - projectCwd: "/repo/project", - worktreePath: "/repo/worktrees/a", - }), - ); - - expect(result).toEqual({ - status: "started", - scriptId: "setup", - scriptName: "Setup", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - }); - expect(open).toHaveBeenCalledWith({ - threadId: "thread-1", - terminalId: "setup-setup", - projectId: ProjectId.make("project-1"), - cwd: "/repo/worktrees/a", - worktreePath: "/repo/worktrees/a", - }); - expect(write).toHaveBeenCalledWith({ - threadId: "thread-1", - terminalId: "setup-setup", - data: "bun install\r", - }); - }); -}); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts deleted file mode 100644 index 0d57a8f261d..00000000000 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ProjectId } from "@t3tools/contracts"; -import { setupProjectScript } from "@t3tools/shared/projectScripts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; -import { - type ProjectSetupScriptRunnerShape, - ProjectSetupScriptRunner, - ProjectSetupScriptRunnerError, -} from "../Services/ProjectSetupScriptRunner.ts"; - -const makeProjectSetupScriptRunner = Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const terminalManager = yield* TerminalManager; - - const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => - Effect.gen(function* () { - const project = - (input.projectId - ? yield* projectionSnapshotQuery - .getProjectShellById(ProjectId.make(input.projectId)) - .pipe(Effect.map(Option.getOrUndefined)) - : null) ?? - (input.projectCwd - ? yield* projectionSnapshotQuery - .getActiveProjectByWorkspaceRoot(input.projectCwd) - .pipe(Effect.map(Option.getOrUndefined)) - : null) ?? - null; - - if (!project) { - return yield* new ProjectSetupScriptRunnerError({ - message: "Project was not found for setup script execution.", - }); - } - - const script = setupProjectScript(project.scripts); - if (!script) { - return { - status: "no-script", - } as const; - } - - const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; - const cwd = input.worktreePath; - yield* terminalManager.open({ - threadId: input.threadId, - terminalId, - projectId: project.id, - cwd, - worktreePath: input.worktreePath, - }); - yield* terminalManager.write({ - threadId: input.threadId, - terminalId, - data: `${script.command}\r`, - }); - - return { - status: "started", - scriptId: script.id, - scriptName: script.name, - terminalId, - cwd, - } as const; - }).pipe( - Effect.mapError((cause) => { - if ( - typeof cause === "object" && - cause !== null && - "_tag" in cause && - cause._tag === "ProjectSetupScriptRunnerError" - ) { - return cause as ProjectSetupScriptRunnerError; - } - const message = - typeof cause === "object" && - cause !== null && - "message" in cause && - typeof cause.message === "string" - ? cause.message - : String(cause); - return new ProjectSetupScriptRunnerError({ message }); - }), - ); - - return { - runForThread, - } satisfies ProjectSetupScriptRunnerShape; -}); - -export const ProjectSetupScriptRunnerLive = Layer.effect( - ProjectSetupScriptRunner, - makeProjectSetupScriptRunner, -); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts new file mode 100644 index 00000000000..0b017b22e4e --- /dev/null +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -0,0 +1,197 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-project-favicon-", + }); +}); + +const writeTextFile = Effect.fn("writeTextFile")(function* ( + cwd: string, + relativePath: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); +}); + +const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => + ProjectFaviconResolver.make.pipe( + Effect.provide(WorkspacePaths.layer), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + +it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { + describe("resolvePath", () => { + it.effect("prefers well-known favicon files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + + it.effect("resolves icon hrefs from project source files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("returns null when no icon is present", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + + it.effect("preserves workspace normalization context", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const missingCwd = `${cwd}/missing`; + + const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "normalize-workspace", + workspaceRoot: missingCwd, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError); + }), + ); + + it.effect("preserves non-missing candidate stat failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const faviconPath = path.join(cwd, "favicon.svg"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: faviconPath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "stat-candidate", + workspaceRoot: cwd, + relativePath: "favicon.svg", + absolutePath: faviconPath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("preserves icon source read failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const sourcePath = path.join(cwd, "index.html"); + yield* writeTextFile(cwd, "index.html", ''); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: sourcePath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (filePath, options) => + filePath === sourcePath + ? Effect.fail(cause) + : fileSystem.readFileString(filePath, options), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "read-source", + workspaceRoot: cwd, + relativePath: "index.html", + absolutePath: sourcePath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("skips icon metadata paths outside the workspace", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + + it.effect("continues to later sources after an outside-root icon href", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + yield* writeTextFile(cwd, "public/index.html", ''); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + }); +}); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts new file mode 100644 index 00000000000..e644df06ae6 --- /dev/null +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -0,0 +1,237 @@ +/** + * ProjectFaviconResolver - Effect service contract for project icon discovery. + * + * Resolves a representative favicon or app icon file for a workspace by + * checking common file locations and project source metadata. + * + * @module ProjectFaviconResolver + */ +import * as Context from "effect/Context"; +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"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; + +// Well-known favicon paths checked in order. +const FAVICON_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", + ".idea/icon.svg", +] as const; + +// Files that may contain a or icon metadata declaration. +const ICON_SOURCE_FILES = [ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", +] as const; + +// Matches tags or object-like icon metadata where rel/href can appear in any order. +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; +const LINK_ICON_OBJ_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; + +export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "ProjectFaviconResolutionError", + { + operation: Schema.Literals([ + "normalize-workspace", + "resolve-path", + "stat-candidate", + "read-source", + ]), + workspaceRoot: Schema.String, + relativePath: Schema.optional(Schema.String), + absolutePath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`; + } +} + +/** Service tag for project favicon resolution. */ +export class ProjectFaviconResolver extends Context.Service< + ProjectFaviconResolver, + { + /** + * Resolve a favicon or icon file path for the provided workspace root. + * + * Returns `null` when no candidate icon file can be found. + */ + readonly resolvePath: ( + cwd: string, + ) => Effect.Effect; + } +>()("t3/project/ProjectFaviconResolver") {} + +function extractIconHref(source: string): string | null { + const htmlMatch = source.match(LINK_ICON_HTML_RE); + if (htmlMatch?.[1]) return htmlMatch[1]; + const objMatch = source.match(LINK_ICON_OBJ_RE); + if (objMatch?.[1]) return objMatch[1]; + return null; +} + +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + + const resolveIconHref = (href: string): ReadonlyArray => { + const clean = href.replace(/^\//, ""); + return [path.join("public", clean), clean]; + }; + + const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( + projectCwd: string, + relativeCandidates: ReadonlyArray, + ): Effect.fn.Return { + for (const relativePath of relativeCandidates) { + const candidate = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + .pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => + Effect.succeed( + Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), + ), + }), + ); + if (Option.isNone(candidate)) { + continue; + } + const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: projectCwd, + relativePath, + absolutePath: candidate.value.absolutePath, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { + return candidate.value.absolutePath; + } + } + return null; + }); + + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( + "ProjectFaviconResolver.resolvePath", + )(function* (cwd) { + const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot: cwd, + cause, + }), + ), + ); + for (const candidate of FAVICON_CANDIDATES) { + const existing = yield* findExistingFile(projectCwd, [candidate]); + if (existing) { + return existing; + } + } + + for (const sourceFile of ICON_SOURCE_FILES) { + const sourcePath = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath: sourceFile, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath: sourceFile, + cause, + }), + ), + ); + const source = yield* optionOnNotFound( + fileSystem.readFileString(sourcePath.absolutePath), + ).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "read-source", + workspaceRoot: projectCwd, + relativePath: sourceFile, + absolutePath: sourcePath.absolutePath, + cause, + }), + ), + ); + if (Option.isNone(source)) { + continue; + } + const href = extractIconHref(source.value); + if (!href) { + continue; + } + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); + if (existing) { + return existing; + } + } + + return null; + }); + + return ProjectFaviconResolver.of({ resolvePath }); +}); + +export const layer = Layer.effect(ProjectFaviconResolver, make); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts new file mode 100644 index 00000000000..fdf95df0b99 --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; + +const isProjectSetupScriptOperationError = Schema.is( + ProjectSetupScriptRunner.ProjectSetupScriptOperationError, +); + +const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), + ), + getProjectShellById: (projectId) => + Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + }); + +const makeTerminalManagerLayer = ( + overrides: Pick, +) => + Layer.succeed(TerminalManager.TerminalManager, { + ...overrides, + attachStream: () => Effect.die(new Error("unused")), + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die(new Error("unused")), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + +const testLayer = ( + project: OrchestrationProject, + terminal: Pick, +) => + ProjectSetupScriptRunner.layer.pipe( + Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), + Layer.provideMerge(makeTerminalManagerLayer(terminal)), + ); + +describe("ProjectSetupScriptRunner", () => { + it.effect("returns no-script when no setup script exists", () => { + const open = vi.fn(() => Effect.die("unexpected open")); + const write = vi.fn(() => Effect.die("unexpected write")); + const project = makeProject([]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + + expect(result).toEqual({ status: "no-script" }); + expect(open).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(project, { open, write }))); + }); + + it.effect( + "opens the deterministic setup terminal with worktree env and writes the command", + () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }); + + expect(result).toEqual({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + data: "bun install\r", + }); + }).pipe(Effect.provide(testLayer(project, { open, write }))); + }, + ); + + it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { + const rootCause = new Error("stat failed"); + const terminalError = new TerminalManager.TerminalCwdStatError({ + cwd: "/repo/worktrees/a", + cause: rootCause, + }); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const error = yield* runner + .runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }) + .pipe(Effect.flip); + + expect(isProjectSetupScriptOperationError(error)).toBe(true); + if (isProjectSetupScriptOperationError(error)) { + expect(error.operation).toBe("openTerminal"); + expect(error.threadId).toBe("thread-1"); + expect(error.projectId).toBe("project-1"); + expect(error.worktreePath).toBe("/repo/worktrees/a"); + expect(error.cause).toBe(terminalError); + expect(terminalError.cause).toBe(rootCause); + } + }).pipe( + Effect.provide( + testLayer(project, { + open: () => Effect.fail(terminalError), + write: () => Effect.die("unexpected write"), + }), + ), + ); + }); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts new file mode 100644 index 00000000000..41bf0fabf48 --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -0,0 +1,188 @@ +import { ProjectId } from "@t3tools/contracts"; +import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; + +export interface ProjectSetupScriptRunnerResultNoScript { + readonly status: "no-script"; +} + +export interface ProjectSetupScriptRunnerResultStarted { + readonly status: "started"; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + readonly cwd: string; +} + +export type ProjectSetupScriptRunnerResult = + | ProjectSetupScriptRunnerResultNoScript + | ProjectSetupScriptRunnerResultStarted; + +export interface ProjectSetupScriptRunnerInput { + readonly threadId: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + readonly preferredTerminalId?: string; +} + +export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass()( + "ProjectSetupScriptOperationError", + { + threadId: Schema.String, + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + operation: Schema.Literals(["resolveProject", "openTerminal", "writeCommand"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Project setup script operation '${this.operation}' failed for thread '${this.threadId}' in '${this.worktreePath}'.`; + } +} + +export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorClass()( + "ProjectSetupScriptProjectNotFoundError", + { + threadId: Schema.String, + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + }, +) { + override get message(): string { + return `Project was not found for setup script execution for thread '${this.threadId}' in '${this.worktreePath}'.`; + } +} + +export const ProjectSetupScriptRunnerError = Schema.Union([ + ProjectSetupScriptOperationError, + ProjectSetupScriptProjectNotFoundError, +]); +export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type; + +export class ProjectSetupScriptRunner extends Context.Service< + ProjectSetupScriptRunner, + { + readonly runForThread: ( + input: ProjectSetupScriptRunnerInput, + ) => Effect.Effect; + } +>()("t3/project/ProjectSetupScriptRunner") {} + +export const make = Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const terminalManager = yield* TerminalManager.TerminalManager; + + const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( + "ProjectSetupScriptRunner.runForThread", + )(function* (input) { + const errorContext = { + threadId: input.threadId, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; + const projectById = input.projectId + ? yield* projectionSnapshotQuery.getProjectShellById(ProjectId.make(input.projectId)).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) + : null; + const project = + projectById ?? + (input.projectCwd + ? yield* projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(input.projectCwd).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) + : null); + + if (!project) { + return yield* new ProjectSetupScriptProjectNotFoundError(errorContext); + } + + const script = setupProjectScript(project.scripts); + if (!script) { + return { + status: "no-script", + } as const; + } + + const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + const cwd = input.worktreePath; + const env = projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: input.worktreePath, + }); + + yield* terminalManager + .open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + yield* terminalManager + .write({ + threadId: input.threadId, + terminalId, + data: `${script.command}\r`, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "writeCommand", + cause, + }), + ), + ); + + return { + status: "started", + scriptId: script.id, + scriptName: script.name, + terminalId, + cwd, + } as const; + }); + + return ProjectSetupScriptRunner.of({ runForThread }); +}); + +export const layer = Layer.effect(ProjectSetupScriptRunner, make); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts similarity index 88% rename from apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts rename to apps/server/src/project/RepositoryIdentityResolver.test.ts index 1c985cd8592..a997459e63d 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -7,12 +7,8 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { TestClock } from "effect/testing"; -import * as ProcessRunner from "../../processRunner.ts"; -import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; -import { - makeRepositoryIdentityResolver, - RepositoryIdentityResolverLive, -} from "./RepositoryIdentityResolver.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); @@ -31,8 +27,8 @@ const makeRepositoryIdentityResolverTestLayer = (options: { readonly negativeCacheTtl?: Duration.Input; }) => Layer.effect( - RepositoryIdentityResolver, - makeRepositoryIdentityResolver({ + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make({ cacheCapacity: 16, ...options, }), @@ -49,7 +45,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); @@ -62,7 +58,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("returns the git top-level root path when resolving from a nested workspace", () => @@ -78,7 +74,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(repoRoot, ["init"]); yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(nestedWorkspace); const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); @@ -89,7 +85,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( normalizeResolvedPath(resolvedRepoRoot), ); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("returns null for non-git folders and repos without remotes", () => @@ -104,13 +100,13 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(gitDir, ["init"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const nonGitIdentity = yield* resolver.resolve(nonGitDir); const noRemoteIdentity = yield* resolver.resolve(gitDir); expect(nonGitIdentity).toBeNull(); expect(noRemoteIdentity).toBeNull(); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("prefers upstream over origin when both remotes are configured", () => @@ -124,14 +120,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); expect(identity?.locator.remoteName).toBe("upstream"); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); expect(identity?.displayName).toBe("t3tools/t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("uses the last remote path segment as the repository name for nested groups", () => @@ -144,7 +140,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); @@ -152,7 +148,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.displayName).toBe("t3tools/platform/t3code"); expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect( @@ -166,7 +162,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const initialIdentity = yield* resolver.resolve(cwd); expect(initialIdentity).toBeNull(); @@ -206,7 +202,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const initialIdentity = yield* resolver.resolve(cwd); expect(initialIdentity).not.toBeNull(); expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts similarity index 53% rename from apps/server/src/project/Layers/RepositoryIdentityResolver.ts rename to apps/server/src/project/RepositoryIdentityResolver.ts index d4ae073b953..50608e7704c 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -1,19 +1,33 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; +import { + detectSourceControlProviderFromGitRemoteUrl, + normalizeGitRemoteUrl, +} from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import { - detectSourceControlProviderFromGitRemoteUrl, - normalizeGitRemoteUrl, -} from "@t3tools/shared/git"; -import * as ProcessRunner from "../../processRunner.ts"; -import { +import * as ProcessRunner from "../processRunner.ts"; + +const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); + +export interface RepositoryIdentityResolverOptions { + readonly cacheCapacity?: number; + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +} + +export class RepositoryIdentityResolver extends Context.Service< RepositoryIdentityResolver, - type RepositoryIdentityResolverShape, -} from "../Services/RepositoryIdentityResolver.ts"; + { + readonly resolve: (cwd: string) => Effect.Effect; + } +>()("t3/project/RepositoryIdentityResolver") {} function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); @@ -73,101 +87,88 @@ function buildRepositoryIdentity(input: { }; } -const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); -const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); +const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( + function* (cwd: string) { + const processRunner = yield* ProcessRunner.ProcessRunner; + let cacheKey = cwd; -interface RepositoryIdentityResolverOptions { - readonly cacheCapacity?: number; - readonly positiveCacheTtl?: Duration.Input; - readonly negativeCacheTtl?: Duration.Input; -} + // git is a real executable on every platform — no cmd.exe shell mode, which + // would split paths containing spaces during cmd's re-tokenization. + const topLevelResult = yield* processRunner + .run({ + command: "git", + args: ["-C", cwd, "rev-parse", "--show-toplevel"], + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { + return cacheKey; + } -const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* ( - cwd: string, -) { - const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; + const candidate = topLevelResult.value.stdout.trim(); + if (candidate.length > 0) { + cacheKey = candidate; + } + + return cacheKey; + }, +); - // git is a real executable on every platform — no cmd.exe shell mode, which - // would split paths containing spaces during cmd's re-tokenization. - const topLevelResult = yield* processRunner +const resolveRepositoryIdentityFromCacheKey = Effect.fn( + "RepositoryIdentityResolver.resolveFromCacheKey", +)(function* ( + cacheKey: string, +): Effect.fn.Return { + const processRunner = yield* ProcessRunner.ProcessRunner; + const remoteResult = yield* processRunner .run({ command: "git", - args: ["-C", cwd, "rev-parse", "--show-toplevel"], + args: ["-C", cacheKey, "remote", "-v"], timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); - if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; - } - - const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; + if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { + return null; } - return cacheKey; + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); + return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; }); -const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")( - function* ( - cacheKey: string, - ): Effect.fn.Return { - const processRunner = yield* ProcessRunner.ProcessRunner; - const remoteResult = yield* processRunner - .run({ - command: "git", - args: ["-C", cacheKey, "remote", "-v"], - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.option); - if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { - return null; - } - - const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); - return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; - }, -); +export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( + options: RepositoryIdentityResolverOptions = {}, +) { + const processRunner = yield* ProcessRunner.ProcessRunner; -export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( - function* (options: RepositoryIdentityResolverOptions = {}) { - const processRunner = yield* ProcessRunner.ProcessRunner; + const repositoryIdentityCache = yield* Cache.makeWith( + (cacheKey) => + resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); - const repositoryIdentityCache = yield* Cache.makeWith( - (cacheKey) => - resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ), - { - capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, - timeToLive: Exit.match({ - onSuccess: (value) => - value === null - ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) - : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), - onFailure: () => Duration.zero, - }), - }, + const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ); + return yield* Cache.get(repositoryIdentityCache, cacheKey); + }); - const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( - "RepositoryIdentityResolver.resolve", - )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); - return yield* Cache.get(repositoryIdentityCache, cacheKey); - }); + return RepositoryIdentityResolver.of({ resolve }); +}); - return { - resolve, - } satisfies RepositoryIdentityResolverShape; - }, +export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe( + Layer.provide(ProcessRunner.layer), ); - -export const RepositoryIdentityResolverLive = Layer.effect( - RepositoryIdentityResolver, - makeRepositoryIdentityResolver(), -).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts deleted file mode 100644 index ad1b466e2c7..00000000000 --- a/apps/server/src/project/Services/ProjectFaviconResolver.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * ProjectFaviconResolver - Effect service contract for project icon discovery. - * - * Resolves a representative favicon or app icon file for a workspace by - * checking common file locations and project source metadata. - * - * @module ProjectFaviconResolver - */ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -/** - * ProjectFaviconResolverShape - Service API for project favicon lookup. - */ -export interface ProjectFaviconResolverShape { - /** - * Resolve a favicon or icon file path for the provided workspace root. - * - * Returns `null` when no candidate icon file can be found. - */ - readonly resolvePath: (cwd: string) => Effect.Effect; -} - -/** - * ProjectFaviconResolver - Service tag for project favicon resolution. - */ -export class ProjectFaviconResolver extends Context.Service< - ProjectFaviconResolver, - ProjectFaviconResolverShape ->()("t3/project/Services/ProjectFaviconResolver") {} diff --git a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts deleted file mode 100644 index 17168eda7f1..00000000000 --- a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import type * as Effect from "effect/Effect"; - -export interface ProjectSetupScriptRunnerResultNoScript { - readonly status: "no-script"; -} - -export interface ProjectSetupScriptRunnerResultStarted { - readonly status: "started"; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - readonly cwd: string; -} - -export type ProjectSetupScriptRunnerResult = - | ProjectSetupScriptRunnerResultNoScript - | ProjectSetupScriptRunnerResultStarted; - -export interface ProjectSetupScriptRunnerInput { - readonly threadId: string; - readonly projectId?: string; - readonly projectCwd?: string; - readonly worktreePath: string; - readonly preferredTerminalId?: string; -} - -export class ProjectSetupScriptRunnerError extends Data.TaggedError( - "ProjectSetupScriptRunnerError", -)<{ - readonly message: string; -}> {} - -export interface ProjectSetupScriptRunnerShape { - readonly runForThread: ( - input: ProjectSetupScriptRunnerInput, - ) => Effect.Effect; -} - -export class ProjectSetupScriptRunner extends Context.Service< - ProjectSetupScriptRunner, - ProjectSetupScriptRunnerShape ->()("t3/project/Services/ProjectSetupScriptRunner") {} diff --git a/apps/server/src/project/Services/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts deleted file mode 100644 index ef0b128c6f7..00000000000 --- a/apps/server/src/project/Services/RepositoryIdentityResolver.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { RepositoryIdentity } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export interface RepositoryIdentityResolverShape { - readonly resolve: (cwd: string) => Effect.Effect; -} - -export class RepositoryIdentityResolver extends Context.Service< - RepositoryIdentityResolver, - RepositoryIdentityResolverShape ->()("t3/project/Services/RepositoryIdentityResolver") {} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b126028f813..f2b04b3a282 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -20,12 +20,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { @@ -48,6 +48,11 @@ import { normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -83,7 +88,8 @@ export type ClaudeDriverEnv = | HttpClient.HttpClient | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { @@ -114,6 +120,7 @@ export const ClaudeDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ @@ -163,16 +170,19 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); - const snapshot = yield* makeManagedServerProvider({ + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingClaudeProvider(settings).pipe(Effect.map(stampIdentity)), + makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, - enrichSnapshot: ({ snapshot, publishSnapshot }) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 441edda479f..ffcc94ca77d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -28,12 +28,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; @@ -47,6 +47,11 @@ import { makePackageManagedProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; import { codexContinuationIdentity, materializeCodexShadowHome, @@ -75,7 +80,8 @@ export type CodexDriverEnv = | HttpClient.HttpClient | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; /** * Stamp instance identity onto a `ServerProvider` snapshot produced by the @@ -111,6 +117,7 @@ export const CodexDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); @@ -163,16 +170,19 @@ export const CodexDriver: ProviderDriver = { Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshot = yield* makeManagedServerProvider({ + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingCodexProvider(settings).pipe(Effect.map(stampIdentity)), + makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, - enrichSnapshot: ({ snapshot, publishSnapshot }) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 12e98293b12..ec78b1665ef 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -3,11 +3,13 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; import { - CodexShadowHomeError, + CodexShadowHomeEntryConflictError, + CodexShadowHomePathConflictError, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; @@ -184,7 +186,14 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - expect(error).toBeInstanceOf(CodexShadowHomeError); + expect(error).toBeInstanceOf(CodexShadowHomePathConflictError); + expect(error).toMatchObject({ + sharedHomePath: sharedHome, + effectiveHomePath: sharedHome, + }); + expect(error.message).toBe( + `Codex shadow home path '${sharedHome}' must be different from the shared home path '${sharedHome}'.`, + ); }), ); @@ -206,7 +215,52 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - expect(error.detail).toContain("already exists and is not a symlink"); + expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError); + expect(error).toMatchObject({ + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + entryName: "config.toml", + linkPath: path.join(shadowHome, "config.toml"), + targetPath: path.join(sharedHome, "config.toml"), + }); + expect(error.message).toBe( + `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, + ); + }), + ); + + it.effect("preserves filesystem operation, paths, and cause", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedRoot = yield* makeTempDir("t3code-codex-shared-root-"); + const sharedHome = path.join(sharedRoot, "shared-home"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(sharedHome, "not a directory\n"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + + expect(error._tag).toBe("CodexShadowHomeFileSystemError"); + if (error._tag !== "CodexShadowHomeFileSystemError") { + return expect.fail("Expected CodexShadowHomeFileSystemError"); + } + expect(error).toMatchObject({ + operation: "makeDirectory", + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + }); + expect(error.path.startsWith(sharedHome)).toBe(true); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect(error.message).toBe( + `Codex shadow home filesystem operation 'makeDirectory' failed for '${error.path}'.`, + ); }), ); }); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 5a7132224ef..d2d09e9d844 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -63,18 +63,71 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi }; }); -export class CodexShadowHomeError extends Schema.TaggedErrorClass()( - "CodexShadowHomeError", +const CodexShadowHomeContext = { + sharedHomePath: Schema.String, + effectiveHomePath: Schema.String, +}; + +export class CodexShadowHomeFileSystemError extends Schema.TaggedErrorClass()( + "CodexShadowHomeFileSystemError", + { + ...CodexShadowHomeContext, + operation: Schema.Literals(["readLink", "makeDirectory", "readDirectory", "remove", "symlink"]), + path: Schema.String, + targetPath: Schema.optional(Schema.String), + entryName: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const target = this.targetPath === undefined ? "" : ` to '${this.targetPath}'`; + return `Codex shadow home filesystem operation '${this.operation}' failed for '${this.path}'${target}.`; + } +} + +export class CodexShadowHomePathConflictError extends Schema.TaggedErrorClass()( + "CodexShadowHomePathConflictError", + CodexShadowHomeContext, +) { + override get message(): string { + return `Codex shadow home path '${this.effectiveHomePath}' must be different from the shared home path '${this.sharedHomePath}'.`; + } +} + +export class CodexShadowHomeEntryConflictError extends Schema.TaggedErrorClass()( + "CodexShadowHomeEntryConflictError", { - detail: Schema.String, - cause: Schema.optional(Schema.Unknown), + ...CodexShadowHomeContext, + entryName: Schema.String, + linkPath: Schema.String, + targetPath: Schema.String, }, ) { override get message(): string { - return this.detail; + return `Cannot create Codex shadow home entry '${this.entryName}' because '${this.linkPath}' already exists and is not a symlink.`; } } -const isCodexShadowHomeError = Schema.is(CodexShadowHomeError); + +export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedErrorClass()( + "CodexShadowHomePrivateEntrySymlinkError", + { + ...CodexShadowHomeContext, + entryName: Schema.String, + path: Schema.String, + }, +) { + override get message(): string { + return `Codex shadow home private entry '${this.entryName}' at '${this.path}' must be a real file, not a symlink.`; + } +} + +export const CodexShadowHomeError = Schema.Union([ + CodexShadowHomeFileSystemError, + CodexShadowHomePathConflictError, + CodexShadowHomeEntryConflictError, + CodexShadowHomePrivateEntrySymlinkError, +]); +export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; type LinkState = | { @@ -88,21 +141,6 @@ type LinkState = readonly target: string; }; -function toShadowHomeError(cause: unknown): CodexShadowHomeError { - return isCodexShadowHomeError(cause) - ? cause - : new CodexShadowHomeError({ - detail: "Failed to materialize Codex shadow home.", - cause, - }); -} - -function normalizeShadowHomeError( - effect: Effect.Effect, -): Effect.Effect { - return effect.pipe(Effect.mapError(toShadowHomeError)); -} - function isNotSymlinkError(error: PlatformError.PlatformError): boolean { const cause = error.reason.cause; return ( @@ -114,78 +152,151 @@ function isNotSymlinkError(error: PlatformError.PlatformError): boolean { ); } -const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* ( - fileSystem: FileSystem.FileSystem, - linkPath: string, -): Effect.fn.Return { - return yield* fileSystem.readLink(linkPath).pipe( +const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; + readonly entryName: string; + readonly linkPath: string; +}): Effect.fn.Return { + return yield* input.fileSystem.readLink(input.linkPath).pipe( Effect.map((target): LinkState => ({ _tag: "Symlink", target })), - Effect.catch((error) => { - if (error.reason._tag === "NotFound") { - return Effect.succeed({ _tag: "Missing" }); - } - if (isNotSymlinkError(error)) { - return Effect.succeed({ _tag: "NotSymlink" }); - } - return Effect.fail(toShadowHomeError(error)); + Effect.catchTags({ + PlatformError: (cause) => { + if (cause.reason._tag === "NotFound") { + return Effect.succeed({ _tag: "Missing" }); + } + if (isNotSymlinkError(cause)) { + return Effect.succeed({ _tag: "NotSymlink" }); + } + return new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "readLink", + path: input.linkPath, + entryName: input.entryName, + cause, + }); + }, }), ); }); const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; - readonly shadowPath: string; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; readonly entryName: string; }): Effect.fn.Return { const path = yield* Path.Path; - const privatePath = path.join(input.shadowPath, input.entryName); - const state = yield* readLinkState(input.fileSystem, privatePath); + const privatePath = path.join(input.effectiveHomePath, input.entryName); + const state = yield* readLinkState({ + ...input, + linkPath: privatePath, + }); if (state._tag === "Symlink") { - yield* normalizeShadowHomeError(input.fileSystem.remove(privatePath)); + yield* input.fileSystem.remove(privatePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: privatePath, + entryName: input.entryName, + cause, + }), + }), + ); } }); const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; - readonly shadowPath: string; - readonly sharedPath: string; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; readonly entryName: string; }): Effect.fn.Return { const path = yield* Path.Path; - const target = path.join(input.sharedPath, input.entryName); - const link = path.join(input.shadowPath, input.entryName); - const state = yield* readLinkState(input.fileSystem, link); + const target = path.join(input.sharedHomePath, input.entryName); + const link = path.join(input.effectiveHomePath, input.entryName); + const state = yield* readLinkState({ + ...input, + linkPath: link, + }); if (state._tag === "NotSymlink") { - return yield* new CodexShadowHomeError({ - detail: `Cannot create Codex shadow home because '${link}' already exists and is not a symlink.`, + return yield* new CodexShadowHomeEntryConflictError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName: input.entryName, + linkPath: link, + targetPath: target, }); } + const createLink = input.fileSystem.symlink(target, link).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "symlink", + path: link, + targetPath: target, + entryName: input.entryName, + cause, + }), + }), + ); + if (state._tag === "Missing") { - return yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + return yield* createLink; } const resolvedExisting = path.resolve(path.dirname(link), state.target); if (resolvedExisting !== target) { - yield* normalizeShadowHomeError(input.fileSystem.remove(link)); - yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + yield* input.fileSystem.remove(link).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + yield* createLink; } }); -const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")(function* ( - fileSystem: FileSystem.FileSystem, - shadowPath: string, -): Effect.fn.Return { - const path = yield* Path.Path; - const authPath = path.join(shadowPath, "auth.json"); - const state = yield* readLinkState(fileSystem, authPath); - if (state._tag === "Symlink") { - return yield* new CodexShadowHomeError({ - detail: `Codex shadow auth file '${authPath}' must be a real file, not a symlink.`, +const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; + }): Effect.fn.Return { + const path = yield* Path.Path; + const entryName = "auth.json"; + const authPath = path.join(input.effectiveHomePath, entryName); + const state = yield* readLinkState({ + ...input, + entryName, + linkPath: authPath, }); - } -}); + if (state._tag === "Symlink") { + return yield* new CodexShadowHomePrivateEntrySymlinkError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName, + path: authPath, + }); + } + }, +); export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( layout: CodexHomeLayout, @@ -194,31 +305,51 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" const effectiveHomePath = layout.effectiveHomePath; if (!effectiveHomePath) return; if (layout.sharedHomePath === effectiveHomePath) { - return yield* new CodexShadowHomeError({ - detail: "Codex shadow home path must be different from the shared home path.", + return yield* new CodexShadowHomePathConflictError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, }); } const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* normalizeShadowHomeError( - Effect.all( - [ - fileSystem.makeDirectory(layout.sharedHomePath, { recursive: true }), - fileSystem.makeDirectory(effectiveHomePath, { recursive: true }), - ...KNOWN_SHARED_DIRECTORIES.map((directory) => - fileSystem.makeDirectory(path.join(layout.sharedHomePath, directory), { - recursive: true, + const makeDirectory = (directoryPath: string) => + fileSystem.makeDirectory(directoryPath, { recursive: true }).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "makeDirectory", + path: directoryPath, + cause, }), - ), - ], - { concurrency: "unbounded" }, - ), + }), + ); + + yield* Effect.all( + [ + makeDirectory(layout.sharedHomePath), + makeDirectory(effectiveHomePath), + ...KNOWN_SHARED_DIRECTORIES.map((directory) => + makeDirectory(path.join(layout.sharedHomePath, directory)), + ), + ], + { concurrency: "unbounded" }, ); - const sharedEntryNames = yield* normalizeShadowHomeError( - fileSystem.readDirectory(layout.sharedHomePath), + const sharedEntryNames = yield* fileSystem.readDirectory(layout.sharedHomePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "readDirectory", + path: layout.sharedHomePath, + cause, + }), + }), ); const entries = new Set(KNOWN_SHARED_DIRECTORIES); for (const entryName of sharedEntryNames) { @@ -234,7 +365,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" ? Effect.void : removePrivateSymlink({ fileSystem, - shadowPath: effectiveHomePath, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, entryName, }), { discard: true }, @@ -248,15 +380,19 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" } return ensureSymlink({ fileSystem, - shadowPath: effectiveHomePath, - sharedPath: layout.sharedHomePath, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, entryName, }); }, { discard: true }, ); - yield* ensureShadowAuthIsPrivate(fileSystem, effectiveHomePath); + yield* ensureShadowAuthIsPrivate({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + }); }); export function codexContinuationIdentity(layout: CodexHomeLayout) { diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ba532864c45..c394a7d1b43 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -18,11 +18,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; @@ -45,6 +45,11 @@ import { makeStaticProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -66,7 +71,8 @@ export type CursorDriverEnv = | HttpClient.HttpClient | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { @@ -98,6 +104,7 @@ export const CursorDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -130,21 +137,23 @@ export const CursorDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); - const snapshot = yield* makeManagedServerProvider({ + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - buildInitialCursorProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, // Model catalog and capabilities come exclusively from Cursor's // list_available_models extension method during provider checks. enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => enrichCursorSnapshot({ - settings, + settings: settings.provider, snapshot: currentSnapshot, maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, publishSnapshot, stampIdentity, httpClient, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index ab01439ffd3..d855d1a4515 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -5,11 +5,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeGrokAdapter } from "../Layers/GrokAdapter.ts"; @@ -32,6 +32,11 @@ import { makeStaticProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); @@ -50,7 +55,8 @@ export type GrokDriverEnv = | HttpClient.HttpClient | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { @@ -80,6 +86,7 @@ export const GrokDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -110,18 +117,20 @@ export const GrokDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshot = yield* makeManagedServerProvider({ + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - buildInitialGrokProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, - enrichSnapshot: ({ snapshot: currentSnapshot, publishSnapshot }) => + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => enrichGrokSnapshot({ snapshot: currentSnapshot, maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, publishSnapshot, httpClient, }), diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index e7216f83366..6342d176590 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -19,12 +19,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { @@ -47,6 +47,11 @@ import { normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -80,7 +85,8 @@ export type OpenCodeDriverEnv = | OpenCodeRuntime | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { @@ -111,6 +117,7 @@ export const OpenCodeDriver: ProviderDriver const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -142,21 +149,26 @@ export const OpenCodeDriver: ProviderDriver processEnv, ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); - const snapshot = yield* makeManagedServerProvider({ - maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, - initialSnapshot: (settings) => - makePendingOpenCodeProvider(settings).pipe(Effect.map(stampIdentity)), - checkProvider, - enrichSnapshot: ({ snapshot, publishSnapshot }) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), - refreshInterval: SNAPSHOT_REFRESH_INTERVAL, - }).pipe( + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>( + { + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }, + ).pipe( Effect.mapError( (cause) => new ProviderDriverError({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 916c9d077dd..191bf8e27db 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { @@ -35,7 +35,7 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { ProviderAdapterValidationError } from "../Errors.ts"; +import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -298,6 +298,44 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("retains Claude session startup causes without exposing their messages", () => { + const cause = new Error("credential material that must remain in the cause chain"); + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + throw cause; + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const error = yield* adapter + .startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderAdapterProcessError); + assert.equal(error.detail, "Failed to start Claude runtime session."); + assert.strictEqual(error.cause, cause); + assert.notMatch(error.message, /credential material/u); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("derives bypass permission mode from full-access runtime policy", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -395,7 +433,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.env?.HOME, path.join(os.homedir(), ".claude-work")); + assert.equal(createInput?.options.env?.HOME, NodePath.join(NodeOS.homedir(), ".claude-work")); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -649,7 +687,7 @@ describe("ClaudeAdapterLive", () => { }); it.effect("embeds image attachments in Claude user messages", () => { - const baseDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-")); + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); const harness = makeHarness({ cwd: "/tmp/project-claude-attachments", baseDir, @@ -657,7 +695,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => - rmSync(baseDir, { + NodeFS.rmSync(baseDir, { recursive: true, force: true, }), @@ -674,9 +712,9 @@ describe("ClaudeAdapterLive", () => { mimeType: "image/png", sizeBytes: 4, }; - const attachmentPath = path.join(attachmentsDir, attachmentRelativePath(attachment)); - mkdirSync(path.dirname(attachmentPath), { recursive: true }); - writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); + const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); const session = yield* adapter.startSession({ threadId: THREAD_ID, @@ -1365,19 +1403,14 @@ describe("ClaudeAdapterLive", () => { it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - const adapter = yield* ClaudeAdapter; const runtimeEvents: Array = []; - const runtimeEventsFiber = runFork( - Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ), - ); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -1430,6 +1463,57 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps Claude stream failure events structural", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.fail(new Error("credential material that must stay in the cause chain")); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + runtimeEventsFiber.interruptUnsafe(); + + const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + assert.equal(runtimeError?.type, "runtime.error"); + if (runtimeError?.type === "runtime.error") { + assert.equal(runtimeError.payload.message, "Claude runtime stream failed."); + assert.deepEqual(runtimeError.payload.detail, { + failureCount: 1, + failureTags: ["ProviderAdapterProcessError"], + }); + } + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "failed"); + assert.equal(completed.payload.errorMessage, "Claude runtime stream failed."); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the previous session before replacing an existing thread session", () => { const queries: FakeClaudeQuery[] = []; const layer = Layer.effect( @@ -1542,14 +1626,12 @@ describe("ClaudeAdapterLive", () => { ); return Effect.gen(function* () { - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = runFork( - Stream.runForEach(adapter.streamEvents, () => Effect.void), - ); + const runtimeEventsFiber = yield* Stream.runForEach( + adapter.streamEvents, + () => Effect.void, + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 95cb249e82c..29555353d34 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -250,21 +250,8 @@ function toMessage(cause: unknown, fallback: string): string { return fallback; } -function toProcessError( - cause: unknown, - fallback: string, - threadId: ThreadId, -): ProviderAdapterProcessError { - return new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: toMessage(cause, fallback), - cause, - }); -} - function normalizeClaudeStreamMessages( - cause: Cause.Cause<{ readonly message: string }>, + cause: Cause.Cause, ): ReadonlyArray { const errors: Array = []; for (const error of Cause.prettyErrors(cause)) { @@ -298,27 +285,17 @@ function isClaudeInterruptedMessage(message: string): boolean { ); } -function isClaudeInterruptedCause(cause: Cause.Cause<{ readonly message: string }>): boolean { +function isClaudeInterruptedCause(cause: Cause.Cause): boolean { return ( Cause.hasInterruptsOnly(cause) || - normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) + normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) || + cause.reasons.some( + (reason) => + Cause.isFailReason(reason) && isClaudeInterruptedMessage(toMessage(reason.error.cause, "")), + ) ); } -function messageFromClaudeStreamCause( - cause: Cause.Cause<{ readonly message: string }>, - fallback: string, -): string { - return normalizeClaudeStreamMessages(cause)[0] ?? fallback; -} - -function interruptionMessageFromClaudeCause( - cause: Cause.Cause<{ readonly message: string }>, -): string { - const message = messageFromClaudeStreamCause(cause, "Claude runtime interrupted."); - return isClaudeInterruptedMessage(message) ? "Claude runtime interrupted." : message; -} - function resultErrorsText(result: SDKResultMessage): string { return "errors" in result && Array.isArray(result.errors) ? result.errors.join(" ").toLowerCase() @@ -1005,7 +982,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( new ProviderAdapterRequestError({ provider: PROVIDER, method: "turn/start", - detail: toMessage(cause, "Failed to read attachment file."), + detail: "Failed to read attachment file.", cause, }), ), @@ -1243,7 +1220,7 @@ function toRequestError(threadId: ThreadId, method: string, cause: unknown): Pro return new ProviderAdapterRequestError({ provider: PROVIDER, method, - detail: toMessage(cause, `${method} failed`), + detail: `${method} failed`, cause, }); } @@ -2908,18 +2885,27 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const runSdkStream = ( context: ClaudeSessionContext, ): Effect.Effect => - Stream.fromAsyncIterable(context.query, (cause) => - toProcessError(cause, "Claude runtime stream failed.", context.session.threadId), + Stream.fromAsyncIterable( + context.query, + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: "Claude runtime stream failed.", + cause, + }), ).pipe( Stream.takeWhile(() => !context.stopped), Stream.runForEach((message) => handleSdkMessage(context, message).pipe( - Effect.mapError((cause) => - toProcessError( - cause, - "Failed to process Claude runtime event.", - context.session.threadId, - ), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: "Failed to process Claude runtime event.", + cause, + }), ), ), ), @@ -2936,15 +2922,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (Exit.isFailure(exit)) { if (isClaudeInterruptedCause(exit.cause)) { if (context.turnState) { - yield* completeTurn( - context, - "interrupted", - interruptionMessageFromClaudeCause(exit.cause), - ); + yield* completeTurn(context, "interrupted", "Claude runtime interrupted."); } } else { - const message = messageFromClaudeStreamCause(exit.cause, "Claude runtime stream failed."); - yield* emitRuntimeError(context, message, Cause.pretty(exit.cause)); + const failures = exit.cause.reasons.flatMap((reason) => + Cause.isFailReason(reason) ? [reason.error] : [], + ); + const message = failures[0]?.detail ?? "Claude runtime stream failed."; + yield* emitRuntimeError(context, message, { + failureCount: failures.length, + failureTags: failures.map((failure) => failure._tag), + }); yield* completeTurn(context, "failed", message); } } else if (context.turnState) { @@ -3002,12 +2990,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: context.session.threadId, - detail: toMessage(cause, "Failed to close Claude runtime query."), + detail: "Failed to close Claude runtime query.", cause, }), }).pipe( - Effect.catch((cause) => - emitRuntimeError(context, "Failed to close Claude runtime query.", cause), + Effect.catch((error) => + emitRuntimeError(context, "Failed to close Claude runtime query.", { + errorTag: error._tag, + provider: error.provider, + threadId: error.threadId, + detail: error.detail, + }), ), ); @@ -3528,7 +3521,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId, - detail: toMessage(cause, "Failed to start Claude runtime session."), + detail: "Failed to start Claude runtime session.", cause, }), }); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d677de7a313..bd5f7ebffc4 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -31,7 +31,6 @@ import { buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, - detailFromResult, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -661,6 +660,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; + yield* Effect.logWarning("Claude Agent CLI health check failed.", { + errorTag: error._tag, + }); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -673,7 +675,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Claude Agent CLI (`claude`) is not installed or not on PATH." - : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : "Failed to execute Claude Agent CLI health check.", }, }); } @@ -698,7 +700,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const version = versionProbe.success.value; const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); if (version.code !== 0) { - const detail = detailFromResult(version); + yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { + exitCode: version.code, + stdoutLength: version.stdout.length, + stderrLength: version.stderr.length, + }); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -709,9 +715,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: parsedVersion, status: "error", auth: { status: "unknown" }, - message: detail - ? `Claude Agent CLI is installed but failed to run. ${detail}` - : "Claude Agent CLI is installed but failed to run.", + message: "Claude Agent CLI is installed but failed to run.", }, }); } diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 98f5c4be6be..0e60bb87072 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import assert from "node:assert/strict"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeAssert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { ApprovalRequestId, CodexSettings, @@ -260,8 +260,8 @@ validationLayer("CodexAdapterLive validation", (it) => { }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); - assert.deepStrictEqual( + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.deepStrictEqual( result.failure, new ProviderAdapterValidationError({ provider: ProviderDriverKind.make("codex"), @@ -269,7 +269,7 @@ validationLayer("CodexAdapterLive validation", (it) => { issue: "Expected provider 'codex' but received 'claudeAgent'.", }), ); - assert.equal(validationRuntimeFactory.factory.mock.calls.length, 0); + NodeAssert.equal(validationRuntimeFactory.factory.mock.calls.length, 0); }), ); it.effect("maps codex model options before starting a session", () => @@ -286,7 +286,7 @@ validationLayer("CodexAdapterLive validation", (it) => { runtimeMode: "full-access", }); - assert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { + NodeAssert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { binaryPath: "codex", cwd: process.cwd(), environment: mergeProviderSessionEnvironment(undefined, undefined), @@ -330,10 +330,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); - assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); - assert.equal(result.failure.provider, "codex"); - assert.equal(result.failure.threadId, "sess-missing"); + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); + NodeAssert.equal(result.failure.provider, "codex"); + NodeAssert.equal(result.failure.threadId, "sess-missing"); }), ); @@ -346,7 +346,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { runtimeMode: "full-access", }); const runtime = sessionRuntimeFactory.lastRuntime; - assert.ok(runtime); + NodeAssert.ok(runtime); runtime.sendTurnImpl.mockClear(); yield* Effect.ignore( @@ -361,7 +361,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); - assert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { + NodeAssert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { input: "hello", model: "gpt-5.3-codex", effort: "high", @@ -397,7 +397,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { runtimeMode: "full-access", }); const runtime = customRuntimeFactory.lastRuntime; - assert.ok(runtime); + NodeAssert.ok(runtime); runtime.sendTurnImpl.mockClear(); yield* Effect.ignore( @@ -416,7 +416,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); - assert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { + NodeAssert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { input: "hello", model: "gpt-5.3-codex", effort: "high", @@ -453,7 +453,7 @@ function startLifecycleRuntime() { runtimeMode: "full-access", }); const runtime = lifecycleRuntimeFactory.lastRuntime; - assert.ok(runtime); + NodeAssert.ok(runtime); return { adapter, runtime }; }); } @@ -488,17 +488,17 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "item.completed"); + NodeAssert.equal(firstEvent.value.type, "item.completed"); if (firstEvent.value.type !== "item.completed") { return; } - assert.equal(firstEvent.value.itemId, "msg_1"); - assert.equal(firstEvent.value.turnId, "turn-1"); - assert.equal(firstEvent.value.payload.itemType, "assistant_message"); + NodeAssert.equal(firstEvent.value.itemId, "msg_1"); + NodeAssert.equal(firstEvent.value.turnId, "turn-1"); + NodeAssert.equal(firstEvent.value.payload.itemType, "assistant_message"); }), ); @@ -535,13 +535,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { return; } - assert.equal(firstEvent.value.payload.itemType, "mcp_tool_call"); - assert.equal(firstEvent.value.payload.title, "t3-code · preview_status"); - assert.deepStrictEqual(firstEvent.value.payload.data, { + NodeAssert.equal(firstEvent.value.payload.itemType, "mcp_tool_call"); + NodeAssert.equal(firstEvent.value.payload.title, "t3-code · preview_status"); + NodeAssert.deepStrictEqual(firstEvent.value.payload.data, { completedAtMs: 1_778_000_000_000, threadId: "thread-1", turnId: "turn-1", @@ -589,16 +589,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "turn.proposed.completed"); + NodeAssert.equal(firstEvent.value.type, "turn.proposed.completed"); if (firstEvent.value.type !== "turn.proposed.completed") { return; } - assert.equal(firstEvent.value.turnId, "turn-1"); - assert.equal(firstEvent.value.payload.planMarkdown, "## Final plan\n\n- one\n- two"); + NodeAssert.equal(firstEvent.value.turnId, "turn-1"); + NodeAssert.equal(firstEvent.value.payload.planMarkdown, "## Final plan\n\n- one\n- two"); }), ); @@ -626,16 +626,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "turn.proposed.delta"); + NodeAssert.equal(firstEvent.value.type, "turn.proposed.delta"); if (firstEvent.value.type !== "turn.proposed.delta") { return; } - assert.equal(firstEvent.value.turnId, "turn-1"); - assert.equal(firstEvent.value.payload.delta, "## Final plan"); + NodeAssert.equal(firstEvent.value.turnId, "turn-1"); + NodeAssert.equal(firstEvent.value.payload.delta, "## Final plan"); }), ); @@ -657,16 +657,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "session.exited"); + NodeAssert.equal(firstEvent.value.type, "session.exited"); if (firstEvent.value.type !== "session.exited") { return; } - assert.equal(firstEvent.value.threadId, "thread-1"); - assert.equal(firstEvent.value.payload.reason, "Session stopped"); + NodeAssert.equal(firstEvent.value.threadId, "thread-1"); + NodeAssert.equal(firstEvent.value.payload.reason, "Session stopped"); }), ); @@ -695,16 +695,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "runtime.warning"); + NodeAssert.equal(firstEvent.value.type, "runtime.warning"); if (firstEvent.value.type !== "runtime.warning") { return; } - assert.equal(firstEvent.value.turnId, "turn-1"); - assert.equal(firstEvent.value.payload.message, "Reconnecting... 2/5"); + NodeAssert.equal(firstEvent.value.turnId, "turn-1"); + NodeAssert.equal(firstEvent.value.payload.message, "Reconnecting... 2/5"); }), ); @@ -726,16 +726,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "runtime.warning"); + NodeAssert.equal(firstEvent.value.type, "runtime.warning"); if (firstEvent.value.type !== "runtime.warning") { return; } - assert.equal(firstEvent.value.turnId, "turn-1"); - assert.equal( + NodeAssert.equal(firstEvent.value.turnId, "turn-1"); + NodeAssert.equal( firstEvent.value.payload.message, "The filename or extension is too long. (os error 206)", ); @@ -763,16 +763,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "thread.realtime.started"); + NodeAssert.equal(firstEvent.value.type, "thread.realtime.started"); if (firstEvent.value.type !== "thread.realtime.started") { return; } - assert.equal(firstEvent.value.threadId, "thread-1"); - assert.equal(firstEvent.value.payload.realtimeSessionId, "realtime-session-1"); + NodeAssert.equal(firstEvent.value.threadId, "thread-1"); + NodeAssert.equal(firstEvent.value.payload.realtimeSessionId, "realtime-session-1"); }), ); @@ -795,17 +795,17 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "runtime.error"); + NodeAssert.equal(firstEvent.value.type, "runtime.error"); if (firstEvent.value.type !== "runtime.error") { return; } - assert.equal(firstEvent.value.turnId, "turn-1"); - assert.equal(firstEvent.value.payload.class, "provider_error"); - assert.equal( + NodeAssert.equal(firstEvent.value.turnId, "turn-1"); + NodeAssert.equal(firstEvent.value.payload.class, "provider_error"); + NodeAssert.equal( firstEvent.value.payload.message, "2026-03-31T18:14:06.833399Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 503 Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses", ); @@ -835,15 +835,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "request.resolved"); + NodeAssert.equal(firstEvent.value.type, "request.resolved"); if (firstEvent.value.type !== "request.resolved") { return; } - assert.equal(firstEvent.value.payload.requestType, "command_execution_approval"); + NodeAssert.equal(firstEvent.value.payload.requestType, "command_execution_approval"); }), ); @@ -870,15 +870,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "request.resolved"); + NodeAssert.equal(firstEvent.value.type, "request.resolved"); if (firstEvent.value.type !== "request.resolved") { return; } - assert.equal(firstEvent.value.payload.requestType, "file_read_approval"); + NodeAssert.equal(firstEvent.value.payload.requestType, "file_read_approval"); }), ); @@ -906,15 +906,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "user-input.resolved"); + NodeAssert.equal(firstEvent.value.type, "user-input.resolved"); if (firstEvent.value.type !== "user-input.resolved") { return; } - assert.deepEqual(firstEvent.value.payload.answers, { + NodeAssert.deepEqual(firstEvent.value.payload.answers, { scope: [], }); }), @@ -945,20 +945,20 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit(event); const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.equal(events.length, 2); + NodeAssert.equal(events.length, 2); const firstEvent = events[0]; const secondEvent = events[1]; - assert.equal(firstEvent?.type, "session.state.changed"); + NodeAssert.equal(firstEvent?.type, "session.state.changed"); if (firstEvent?.type === "session.state.changed") { - assert.equal(firstEvent.payload.state, "error"); - assert.equal(firstEvent.payload.reason, "Sandbox setup failed"); + NodeAssert.equal(firstEvent.payload.state, "error"); + NodeAssert.equal(firstEvent.payload.reason, "Sandbox setup failed"); } - assert.equal(secondEvent?.type, "runtime.warning"); + NodeAssert.equal(secondEvent?.type, "runtime.warning"); if (secondEvent?.type === "runtime.warning") { - assert.equal(secondEvent.payload.message, "Sandbox setup failed"); + NodeAssert.equal(secondEvent.payload.message, "Sandbox setup failed"); } }), ); @@ -1017,17 +1017,17 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { } satisfies ProviderEvent); const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.equal(events[0]?.type, "user-input.requested"); + NodeAssert.equal(events[0]?.type, "user-input.requested"); if (events[0]?.type === "user-input.requested") { - assert.equal(events[0].requestId, "req-user-input-1"); - assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); - assert.equal(events[0].payload.questions[0]?.multiSelect, false); + NodeAssert.equal(events[0].requestId, "req-user-input-1"); + NodeAssert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); + NodeAssert.equal(events[0].payload.questions[0]?.multiSelect, false); } - assert.equal(events[1]?.type, "user-input.resolved"); + NodeAssert.equal(events[1]?.type, "user-input.resolved"); if (events[1]?.type === "user-input.resolved") { - assert.equal(events[1].requestId, "req-user-input-1"); - assert.deepEqual(events[1].payload.answers, { + NodeAssert.equal(events[1].requestId, "req-user-input-1"); + NodeAssert.deepEqual(events[1].payload.answers, { sandbox_mode: "workspace-write", }); } @@ -1071,16 +1071,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { } satisfies ProviderEvent); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "thread.token-usage.updated"); + NodeAssert.equal(firstEvent.value.type, "thread.token-usage.updated"); if (firstEvent.value.type !== "thread.token-usage.updated") { return; } - assert.deepEqual(firstEvent.value.payload.usage, { + NodeAssert.deepEqual(firstEvent.value.payload.usage, { usedTokens: 126, totalProcessedTokens: 11_839, maxTokens: 258_400, @@ -1130,15 +1130,15 @@ scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => { }); const runtime = scopedLifecycleRuntimeFactory.lastRuntime; - assert.ok(runtime); + NodeAssert.ok(runtime); yield* adapter.stopSession(asThreadId("thread-stop")); - assert.equal(runtime.closeImpl.mock.calls.length, 1); - assert.deepStrictEqual(scopedLifecycleRuntimeFactory.releasedThreadIds, [ + NodeAssert.equal(runtime.closeImpl.mock.calls.length, 1); + NodeAssert.deepStrictEqual(scopedLifecycleRuntimeFactory.releasedThreadIds, [ asThreadId("thread-stop"), ]); - assert.equal(yield* adapter.hasSession(asThreadId("thread-stop")), false); + NodeAssert.equal(yield* adapter.hasSession(asThreadId("thread-stop")), false); }), ); }); @@ -1175,20 +1175,22 @@ scopedFailureLayer("CodexAdapterLive scoped startup failure", (it) => { }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); - assert.equal(result.failure._tag, "ProviderAdapterProcessError"); - assert.deepStrictEqual(scopedFailureRuntimeFactory.releasedThreadIds, [ + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterProcessError"); + NodeAssert.deepStrictEqual(scopedFailureRuntimeFactory.releasedThreadIds, [ asThreadId("thread-fail"), ]); - assert.equal(yield* adapter.hasSession(asThreadId("thread-fail")), false); + NodeAssert.equal(yield* adapter.hasSession(asThreadId("thread-fail")), false); }), ); }); it.effect("flushes managed native logs when the adapter layer shuts down", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-codex-adapter-native-log-")); - const basePath = path.join(tempDir, "provider-native.ndjson"); + const tempDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-codex-adapter-native-log-"), + ); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); const runtimeFactory = makeRuntimeFactory(); const scope = yield* Scope.make("sequential"); let scopeClosed = false; @@ -1219,7 +1221,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => }); const runtime = runtimeFactory.lastRuntime; - assert.ok(runtime); + NodeAssert.ok(runtime); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); yield* runtime.emit({ @@ -1236,15 +1238,15 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => yield* Scope.close(scope, Exit.void); scopeClosed = true; - const threadLogPath = path.join(tempDir, "thread-logger.log"); - assert.equal(fs.existsSync(threadLogPath), true); - const contents = fs.readFileSync(threadLogPath, "utf8"); - assert.match(contents, /NTIVE: .*"message":"native flush test"/); + const threadLogPath = NodePath.join(tempDir, "thread-logger.log"); + NodeAssert.equal(NodeFS.existsSync(threadLogPath), true); + const contents = NodeFS.readFileSync(threadLogPath, "utf8"); + NodeAssert.match(contents, /NTIVE: .*"message":"native flush test"/); } finally { if (!scopeClosed) { yield* Scope.close(scope, Exit.void); } - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index fb2f36f6438..811c362f1e0 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -7,7 +7,8 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -253,7 +254,7 @@ function parseCodexSkillsListResponse( } const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( - client: CodexClient.CodexAppServerClientShape, + client: CodexClient.CodexAppServerClient["Service"], ) { const models: ServerProviderModel[] = []; let cursor: string | null | undefined = undefined; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 2d303039856..8aeacd870cc 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1,8 +1,9 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; +import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { describe, it } from "vite-plus/test"; +import { describe } from "vite-plus/test"; import { ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; @@ -19,6 +20,23 @@ import { } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +describe("CodexSessionRuntimeIdentifierGenerationError", () => { + it("retains identifier purpose and the random source failure", () => { + const cause = new Error("random source unavailable"); + const error = new CodexErrors.CodexAppServerIdentifierGenerationError({ + purpose: "provider-event", + cause, + }); + + NodeAssert.equal(error.purpose, "provider-event"); + NodeAssert.strictEqual(error.cause, cause); + NodeAssert.equal( + error.message, + "Failed to generate Codex App Server identifier for provider-event.", + ); + }); +}); + function makeThreadOpenResponse( threadId: string, ): CodexRpc.ClientRequestResponsesByMethod["thread/start"] { @@ -43,6 +61,32 @@ function makeThreadOpenResponse( } describe("buildTurnStartParams", () => { + it("keeps invalid turn values only in the schema cause", () => { + const secret = "codex-turn-input-secret-sentinel"; + const error = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + attachments: [ + { + type: "image", + url: { secret } as unknown as string, + }, + ], + }).pipe(Effect.flip), + ); + const { cause, ...directDiagnostics } = error; + + NodeAssert.equal(error.operation, "decode-request-payload"); + NodeAssert.equal(error.method, "turn/start"); + NodeAssert.ok((error.issueCount ?? 0) > 0); + NodeAssert.ok(error.issueKinds?.includes("Pointer")); + NodeAssert.ok((error.maximumPathDepth ?? 0) > 0); + NodeAssert.ok(Schema.isSchemaError(cause)); + NodeAssert.doesNotMatch(error.message, new RegExp(secret)); + NodeAssert.doesNotMatch(JSON.stringify(directDiagnostics), new RegExp(secret)); + }); + it("includes plan collaboration mode when requested", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -55,7 +99,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "never", sandboxPolicy: { @@ -97,7 +141,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "on-request", sandboxPolicy: { @@ -134,7 +178,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "untrusted", sandboxPolicy: { @@ -156,19 +200,19 @@ describe("T3 browser developer instructions", () => { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, ]) { - assert.match(instructions, /t3-code/); - assert.match(instructions, /preview_status/); - assert.match(instructions, /preview_open/); - assert.match(instructions, /Do not switch to global browser skills/); + NodeAssert.match(instructions, /t3-code/); + NodeAssert.match(instructions, /preview_status/); + NodeAssert.match(instructions, /preview_open/); + NodeAssert.match(instructions, /Do not switch to global browser skills/); } }); }); describe("hasConfiguredMcpServer", () => { it("detects inline Codex MCP configuration arguments", () => { - assert.equal(hasConfiguredMcpServer(undefined), false); - assert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); - assert.equal( + NodeAssert.equal(hasConfiguredMcpServer(undefined), false); + NodeAssert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); + NodeAssert.equal( hasConfiguredMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']), true, ); @@ -177,7 +221,7 @@ describe("hasConfiguredMcpServer", () => { describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -189,7 +233,7 @@ describe("isRecoverableThreadResumeError", () => { }); it("ignores non-recoverable resume errors", () => { - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -201,7 +245,7 @@ describe("isRecoverableThreadResumeError", () => { }); it("ignores unrelated missing-resource errors that do not mention threads", () => { - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -210,7 +254,7 @@ describe("isRecoverableThreadResumeError", () => { ), false, ); - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -223,29 +267,29 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { - it("falls back to thread/start when resume fails recoverably", async () => { - const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; - const started = makeThreadOpenResponse("fresh-thread"); - const client = { - request: ( - method: M, - payload: CodexRpc.ClientRequestParamsByMethod[M], - ) => { - calls.push({ method, payload }); - if (method === "thread/resume") { - return Effect.fail( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "thread not found", - }), - ); - } - return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); - }, - }; + it.effect("falls back to thread/start when resume fails recoverably", () => + Effect.gen(function* () { + const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; + const started = makeThreadOpenResponse("fresh-thread"); + const client = { + request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + calls.push({ method, payload }); + if (method === "thread/resume") { + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread not found", + }), + ); + } + return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); + }, + }; - const opened = await Effect.runPromise( - openCodexThread({ + const opened = yield* openCodexThread({ client, threadId: ThreadId.make("thread-1"), runtimeMode: "full-access", @@ -253,51 +297,49 @@ describe("openCodexThread", () => { requestedModel: "gpt-5.3-codex", serviceTier: undefined, resumeThreadId: "stale-thread", - }), - ); + }); - assert.equal(opened.thread.id, "fresh-thread"); - assert.deepStrictEqual( - calls.map((call) => call.method), - ["thread/resume", "thread/start"], - ); - }); + NodeAssert.equal(opened.thread.id, "fresh-thread"); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["thread/resume", "thread/start"], + ); + }), + ); - it("propagates non-recoverable resume failures", async () => { - const client = { - request: ( - method: M, - _payload: CodexRpc.ClientRequestParamsByMethod[M], - ) => { - if (method === "thread/resume") { - return Effect.fail( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "timed out waiting for server", - }), + it.effect("propagates non-recoverable resume failures", () => + Effect.gen(function* () { + const client = { + request: ( + method: M, + _payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + if (method === "thread/resume") { + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "timed out waiting for server", + }), + ); + } + return Effect.succeed( + makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], ); - } - return Effect.succeed( - makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], - ); - }, - }; + }, + }; - await assert.rejects( - Effect.runPromise( - openCodexThread({ - client, - threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", - cwd: "/tmp/project", - requestedModel: "gpt-5.3-codex", - serviceTier: undefined, - resumeThreadId: "stale-thread", - }), - ), - (error: unknown) => - isCodexAppServerRequestError(error) && - error.errorMessage === "timed out waiting for server", - ); - }); + const error = yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: "stale-thread", + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexAppServerRequestError(error)); + NodeAssert.equal(error.errorMessage, "timed out waiting for server"); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index f156e97daea..5611384bbfd 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -27,10 +27,9 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as SchemaIssue from "effect/SchemaIssue"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -98,7 +97,6 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; type CodexServiceTier = NonNullable; @@ -402,7 +400,13 @@ export function buildTurnStartParams(input: { ...(input.effort ? { effort: input.effort } : {}), ...(collaborationMode ? { collaborationMode } : {}), }).pipe( - Effect.mapError((error) => toProtocolParseError("Invalid turn/start request payload", error)), + Effect.mapError((cause) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-request-payload", + cause, + { method: "turn/start" }, + ), + ), ); } @@ -480,7 +484,7 @@ export const openCodexThread = (input: { requestedRuntimeMode: input.runtimeMode, resumeThreadId, recoverable: true, - cause: error.message, + cause: error, }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), ), ); @@ -675,16 +679,6 @@ function toCodexUserInputAnswers( ).pipe(Effect.map((entries) => Object.fromEntries(entries))); } -function toProtocolParseError( - detail: string, - cause: Schema.SchemaError, -): CodexErrors.CodexAppServerProtocolParseError { - return new CodexErrors.CodexAppServerProtocolParseError({ - detail: `${detail}: ${formatSchemaIssue(cause.issue)}`, - cause, - }); -} - function currentProviderThreadId(session: ProviderSession): string | undefined { return readResumeCursorThreadId(session.resumeCursor); } @@ -777,15 +771,16 @@ export const makeCodexSessionRuntime = ( ); const serverNotifications = yield* Queue.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new CodexErrors.CodexAppServerTransportError({ - detail: "Failed to generate Codex runtime identifier.", - cause, - }), - ), - ); + const randomUUIDv4 = (purpose: CodexErrors.CodexAppServerIdentifierPurpose) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerIdentifierGenerationError({ + purpose, + cause, + }), + ), + ); const sessionCreatedAt = yield* nowIso; const initialSession = { @@ -805,7 +800,7 @@ export const makeCodexSessionRuntime = ( const emitEvent = (event: Omit) => Effect.gen(function* () { - const id = yield* randomUUIDv4; + const id = yield* randomUUIDv4("provider-event"); return yield* offerEvent({ id: EventId.make(id), provider: PROVIDER, @@ -973,7 +968,7 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("command-approval-request")); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const decision = yield* Deferred.make(); @@ -1029,7 +1024,9 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const requestId = ApprovalRequestId.make( + yield* randomUUIDv4("file-change-approval-request"), + ); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const decision = yield* Deferred.make(); @@ -1085,7 +1082,7 @@ export const makeCodexSessionRuntime = ( yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); const turnId = TurnId.make(payload.turnId); const itemId = ProviderItemId.make(payload.itemId); const answers = yield* Deferred.make(); @@ -1310,7 +1307,11 @@ export const makeCodexSessionRuntime = ( const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( Effect.mapError((error) => - toProtocolParseError("Invalid turn/start response payload", error), + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "turn/start" }, + ), ), ); const turnId = TurnId.make(response.turn.id); @@ -1351,7 +1352,11 @@ export const makeCodexSessionRuntime = ( }); const response = yield* decodeThreadGoalGetResponse(rawResponse).pipe( Effect.mapError((error) => - toProtocolParseError("Invalid thread/goal/get response payload", error), + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "thread/goal/get" }, + ), ), ); if (response.goal) { diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index c71c6964459..9795e5a0680 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -36,8 +36,8 @@ class CursorAdapter extends Context.Service() "t3/provider/Layers/CursorAdapter.test/CursorAdapter", ) {} -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const mockAgentCommand = "node"; const mockAgentArgs = [mockAgentPath] as const; @@ -45,8 +45,8 @@ async function makeMockAgentWrapper( extraEnv?: Record, options?: { initialDelaySeconds?: number }, ) { - const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-")); - const wrapperPath = path.join(dir, "fake-agent.sh"); + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-agent.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) .join("\n"); @@ -55,8 +55,8 @@ ${envExports} ${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""} exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" `; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); return wrapperPath; } @@ -65,8 +65,8 @@ async function makeProbeWrapper( argvLogPath: string, extraEnv?: Record, ) { - const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-")); - const wrapperPath = path.join(dir, "fake-agent.sh"); + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-")); + const wrapperPath = NodePath.join(dir, "fake-agent.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) .join("\n"); @@ -77,13 +77,13 @@ export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} ${envExports} exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" `; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); return wrapperPath; } async function readArgvLog(filePath: string) { - const raw = await readFile(filePath, "utf8"); + const raw = await NodeFSP.readFile(filePath, "utf8"); return raw .split("\n") .map((line) => line.trim()) @@ -92,7 +92,7 @@ async function readArgvLog(filePath: string) { } async function readJsonLines(filePath: string) { - const raw = await readFile(filePath, "utf8"); + const raw = await NodeFSP.readFile(filePath, "utf8"); return raw .split("\n") .map((line) => line.trim()) @@ -103,7 +103,7 @@ async function readJsonLines(filePath: string) { async function waitForFileContent(filePath: string, attempts = 40) { for (let attempt = 0; attempt < attempts; attempt += 1) { try { - const raw = await readFile(filePath, "utf8"); + const raw = await NodeFSP.readFile(filePath, "utf8"); if (raw.trim().length > 0) { return raw; } @@ -315,9 +315,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-stop-session-close"); const tempDir = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), "cursor-adapter-exit-log-")), + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-adapter-exit-log-")), ); - const exitLogPath = path.join(tempDir, "exit.log"); + const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ @@ -349,9 +349,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-concurrent-start-session"); const tempDir = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), "cursor-adapter-concurrent-exit-log-")), + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-adapter-concurrent-exit-log-")), ); - const exitLogPath = path.join(tempDir, "exit.log"); + const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper( @@ -414,10 +414,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-plan-mode-probe"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); @@ -470,10 +472,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-initial-config-probe"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); @@ -713,10 +717,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const runtimeEvents: Array = []; const settledEventTypes = new Set(); const settledEventsReady = yield* Deferred.make(); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), ); @@ -931,10 +937,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-cancel-probe"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), ); @@ -1192,10 +1200,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-model-switch"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); @@ -1255,10 +1265,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-fast-mode-reset"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); @@ -1339,10 +1351,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-fast-mode-custom-instance"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 383e9511223..148b3eeddf7 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -36,7 +36,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -51,7 +51,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, @@ -127,7 +127,7 @@ interface CursorSessionContext { readonly threadId: ThreadId; session: ProviderSession; readonly scope: Scope.Closeable; - readonly acp: AcpSessionRuntimeShape; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -247,7 +247,7 @@ function resolveRequestedModeId(input: { } function applyRequestedSessionConfiguration(input: { - readonly runtime: AcpSessionRuntimeShape; + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode | undefined; readonly modelSelection: diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 60a7312eea3..78f62ac2123 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect, it } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; import type { CursorSettings } from "@t3tools/contracts"; @@ -24,7 +25,11 @@ import { } from "./CursorProvider.ts"; const runNode = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + >, ): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { @@ -293,6 +298,18 @@ const baseCursorSettings: CursorSettings = { apiEndpoint: "", customModels: [], }; +const cursorAcpDiscoveryFailedMessage = [ + "Cursor ACP model discovery failed.", + "Cursor CLI setup may be incomplete; install or enable the Cursor CLI, restart T3 Code, and try again.", + "See https://cursor.com/docs/cli/installation.", + "Check server logs for ACP details.", +].join(" "); +const missingCursorBinaryPath = "/definitely/not/installed/t3-cursor-agent"; +const cursorCliCommandMissingMessage = [ + `Cursor CLI command \`${missingCursorBinaryPath}\` was not found.`, + `Install or enable the Cursor CLI, make sure \`${missingCursorBinaryPath}\` is on PATH, then restart T3 Code.`, + "See https://cursor.com/docs/cli/installation.", +].join(" "); describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { @@ -338,12 +355,11 @@ describe("buildCursorProviderSnapshot", () => { auth: { status: "unauthenticated" }, message: "Cursor Agent is not authenticated. Run `agent login` and try again.", }, - discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.", + discoveryWarning: cursorAcpDiscoveryFailedMessage, }), ).toMatchObject({ status: "error", - message: - "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.", + message: `Cursor Agent is not authenticated. Run \`agent login\` and try again. ${cursorAcpDiscoveryFailedMessage}`, models: [ { slug: "claude-sonnet-4-6", @@ -411,10 +427,28 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { }); describe("checkCursorProviderStatus", () => { + it("reports the install docs when the Cursor CLI command is missing", async () => { + const provider = await runNode( + checkCursorProviderStatus({ + enabled: true, + binaryPath: missingCursorBinaryPath, + apiEndpoint: "", + customModels: [], + }), + ); + + expect(provider).toMatchObject({ + installed: false, + status: "error", + auth: { status: "unknown" }, + message: cursorCliCommandMissingMessage, + }); + }); + it("passes the injected environment to ACP model discovery", async () => { const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture()); - const provider = await Effect.runPromise( + const provider = await runNode( checkCursorProviderStatus( { enabled: true, @@ -426,7 +460,7 @@ describe("checkCursorProviderStatus", () => { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }, - ).pipe(Effect.provide(NodeServices.layer)), + ), ); expect(provider.models.map((model) => model.slug)).toEqual([ @@ -443,13 +477,13 @@ describe("discoverCursorModelsViaAcp", () => { it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); - const models = await Effect.runPromise( + const models = await runNode( discoverCursorModelsViaAcp({ enabled: true, binaryPath: wrapperPath, apiEndpoint: "", customModels: [], - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.scoped), ); expect(models.map((model) => model.slug)).toEqual([ @@ -465,13 +499,13 @@ describe("discoverCursorModelsViaAcp", () => { makeExitLogFixture("cursor-provider-exit-log-"), ); - await Effect.runPromise( + await runNode( discoverCursorModelsViaAcp({ enabled: true, binaryPath: wrapperPath, apiEndpoint: "", customModels: [], - }).pipe(Effect.provide(NodeServices.layer)), + }), ); const exitLog = await runNode(waitForFileContent(exitLogPath)); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 35d5413714c..cd9b93a4734 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1,4 +1,4 @@ -import * as NodeOs from "node:os"; +import * as NodeOS from "node:os"; import type { CursorSettings, ModelCapabilities, @@ -10,7 +10,7 @@ import type { } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -21,7 +21,8 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { createModelCapabilities, getProviderOptionBooleanSelectionValue, @@ -43,7 +44,7 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"; const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( @@ -61,6 +62,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; +const CURSOR_CLI_INSTALLATION_DOCS_URL = "https://cursor.com/docs/cli/installation"; +const CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE = [ + "Cursor ACP model discovery failed.", + "Cursor CLI setup may be incomplete; install or enable the Cursor CLI, restart T3 Code, and try again.", + `See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`, + "Check server logs for ACP details.", +].join(" "); export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { _meta: { parameterizedModelPicker: true, @@ -416,12 +424,14 @@ const makeCursorAcpProbeRuntime = ( clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), ); - return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); }); const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, - useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, + useRuntime: (acp: AcpSessionRuntime.AcpSessionRuntime["Service"]) => Effect.Effect, environment?: NodeJS.ProcessEnv, ) => makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( @@ -605,6 +615,14 @@ function joinProviderMessages(...messages: ReadonlyArray): s return parts.length > 0 ? parts.join(" ") : undefined; } +function buildCursorCliCommandMissingMessage(binaryPath: string): string { + return [ + `Cursor CLI command \`${binaryPath}\` was not found.`, + `Install or enable the Cursor CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`, + `See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`, + ].join(" "); +} + export function buildCursorProviderSnapshot(input: { readonly checkedAt: string; readonly cursorSettings: CursorSettings; @@ -743,7 +761,7 @@ function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean { const readCursorCliConfigChannel = Effect.fn("readCursorCliConfigChannel")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const configPath = path.join(NodeOs.homedir(), ".cursor", "cli-config.json"); + const configPath = path.join(NodeOS.homedir(), ".cursor", "cli-config.json"); const raw = yield* fileSystem.readFileString(configPath).pipe(Effect.orElseSucceed(() => "")); return parseCursorCliConfigChannel(raw); }); @@ -1003,6 +1021,9 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (Result.isFailure(aboutProbe)) { const error = aboutProbe.failure; + yield* Effect.logWarning("Cursor Agent CLI health check failed.", { + errorTag: error._tag, + }); return buildServerProvider({ presentation: CURSOR_PRESENTATION, enabled: cursorSettings.enabled, @@ -1014,8 +1035,8 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( status: "error", auth: { status: "unknown" }, message: isCommandMissingCause(error) - ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." - : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + ? buildCursorCliCommandMissingMessage(cursorSettings.binaryPath) + : "Failed to execute Cursor Agent CLI health check.", }, }); } @@ -1071,9 +1092,9 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( ); if (Exit.isFailure(discoveryExit)) { yield* Effect.logWarning("Cursor ACP model discovery failed", { - cause: Cause.pretty(discoveryExit.cause), + errorTag: causeErrorTag(discoveryExit.cause), }); - discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; + discoveryWarning = CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE; } else if (Option.isNone(discoveryExit.value)) { discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; } else if (discoveryExit.value.value.length === 0) { @@ -1106,6 +1127,7 @@ export const enrichCursorSnapshot = (input: { readonly settings: CursorSettings; readonly snapshot: ServerProvider; readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; readonly httpClient: HttpClient.HttpClient; @@ -1117,14 +1139,16 @@ export const enrichCursorSnapshot = (input: { return Effect.void; } - return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe( + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), ), Effect.catchCause((cause) => Effect.logWarning("Cursor version advisory enrichment failed", { - cause: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }).pipe(Effect.asVoid), ), ); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 0b1f99d3c11..71ac7831ed4 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -1,14 +1,18 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import * as Schema from "effect/Schema"; import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + function parseLogLine(line: string) { const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); assert.notEqual(match, null); @@ -29,10 +33,42 @@ function parseLogLine(line: string) { } describe("EventNdjsonLogger", () => { + it.effect("logs bounded diagnostics when an event cannot be serialized", () => { + const messages: Array = []; + const logCapture = Logger.make(({ message }) => { + if (Array.isArray(message)) { + messages.push(...message); + } else { + messages.push(message); + } + }); + const secret = "secret-circular-event-value"; + + return Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); + const circular: Record = { secret }; + circular.self = circular; + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); + assert.exists(logger); + if (!logger) return; + yield* logger.write(circular, ThreadId.make("thread-1")); + + const serialized = encodeUnknownJson(messages); + assert.notInclude(serialized, secret); + assert.include(serialized, '"errorTag":"SchemaError"'); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); + }); + it.effect("writes effect-style lines to thread-scoped files", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const basePath = path.join(tempDir, "provider-native.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); try { const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); @@ -51,13 +87,13 @@ describe("EventNdjsonLogger", () => { ); yield* logger.close(); - const threadOnePath = path.join(tempDir, "thread-1.log"); - const threadTwoPath = path.join(tempDir, "thread-2.log"); - assert.equal(fs.existsSync(threadOnePath), true); - assert.equal(fs.existsSync(threadTwoPath), true); + const threadOnePath = NodePath.join(tempDir, "thread-1.log"); + const threadTwoPath = NodePath.join(tempDir, "thread-2.log"); + assert.equal(NodeFS.existsSync(threadOnePath), true); + assert.equal(NodeFS.existsSync(threadTwoPath), true); - const first = parseLogLine(fs.readFileSync(threadOnePath, "utf8").trim()); - const second = parseLogLine(fs.readFileSync(threadTwoPath, "utf8").trim()); + const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim()); + const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim()); assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); assert.equal(first.stream, "NTIVE"); @@ -70,7 +106,7 @@ describe("EventNdjsonLogger", () => { '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', ); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); @@ -79,8 +115,8 @@ describe("EventNdjsonLogger", () => { "falls back to a global segment when orchestration thread id is missing or invalid", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const basePath = path.join(tempDir, "provider-canonical.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); try { const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); @@ -93,10 +129,9 @@ describe("EventNdjsonLogger", () => { yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); yield* logger.close(); - const globalPath = path.join(tempDir, "_global.log"); - assert.equal(fs.existsSync(globalPath), true); - const lines = fs - .readFileSync(globalPath, "utf8") + const globalPath = NodePath.join(tempDir, "_global.log"); + assert.equal(NodeFS.existsSync(globalPath), true); + const lines = NodeFS.readFileSync(globalPath, "utf8") .trim() .split("\n") .map((line) => parseLogLine(line)); @@ -108,15 +143,15 @@ describe("EventNdjsonLogger", () => { assert.equal(lines[1]?.stream, "CANON"); assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); it.effect("serializes concurrent first writes for the same segment", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const basePath = path.join(tempDir, "provider-canonical.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); try { const logger = yield* makeEventNdjsonLogger(basePath, { @@ -137,10 +172,9 @@ describe("EventNdjsonLogger", () => { ); yield* logger.close(); - const globalPath = path.join(tempDir, "_global.log"); - assert.equal(fs.existsSync(globalPath), true); - const lines = fs - .readFileSync(globalPath, "utf8") + const globalPath = NodePath.join(tempDir, "_global.log"); + assert.equal(NodeFS.existsSync(globalPath), true); + const lines = NodeFS.readFileSync(globalPath, "utf8") .trim() .split("\n") .map((line) => parseLogLine(line)); @@ -151,15 +185,15 @@ describe("EventNdjsonLogger", () => { '{"id":"evt-concurrent-2"}', ]); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); it.effect("rotates per-thread files when max size is exceeded", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const basePath = path.join(tempDir, "provider-native.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); try { const logger = yield* makeEventNdjsonLogger(basePath, { @@ -185,8 +219,7 @@ describe("EventNdjsonLogger", () => { yield* logger.close(); const fileStem = "thread-rotate.log"; - const matchingFiles = fs - .readdirSync(tempDir) + const matchingFiles = NodeFS.readdirSync(tempDir) .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) .toSorted(); @@ -203,7 +236,7 @@ describe("EventNdjsonLogger", () => { false, ); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index 04377ad520c..8c20a4c1936 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -6,11 +6,12 @@ * single effect-style text line in a thread-scoped file. Failures are * downgraded to warnings so provider runtime behavior is unaffected. */ -import fs from "node:fs"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import type { ThreadId } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; +import { errorTag } from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Logger from "effect/Logger"; @@ -31,8 +32,8 @@ export type EventNdjsonStream = "native" | "canonical" | "orchestration"; export interface EventNdjsonLogger { readonly filePath: string; - write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; - close: () => Effect.Effect; + write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; + close: () => Effect.Effect; } export interface EventNdjsonLoggerOptions { @@ -91,9 +92,9 @@ const toLogMessage = Effect.fn("toLogMessage")(function* ( ): Effect.fn.Return { return yield* encodeUnknownJsonString(event).pipe( Effect.catch((error) => - logWarning("failed to serialize provider event log record", { error }).pipe( - Effect.as(undefined), - ), + logWarning("failed to serialize provider event log record", { + errorTag: errorTag(error), + }).pipe(Effect.as(undefined)), ), ); }); @@ -124,7 +125,7 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { if (!sinkResult.ok) { yield* logWarning("failed to initialize provider thread log file", { filePath: input.filePath, - error: sinkResult.error, + errorTag: errorTag(sinkResult.error), }); return undefined; } @@ -149,7 +150,7 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { if (!flushResult.ok) { yield* logWarning("provider event log batch flush failed", { filePath: input.filePath, - error: flushResult.error, + errorTag: errorTag(flushResult.error), }); } }), @@ -178,7 +179,7 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function const directoryReady = yield* Effect.sync(() => { try { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true }); return true; } catch (error) { return { ok: false as const, error }; @@ -187,7 +188,7 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function if (directoryReady !== true) { yield* logWarning("failed to create provider event log directory", { filePath, - error: directoryReady.error, + errorTag: errorTag(directoryReady.error), }); return undefined; } @@ -211,7 +212,7 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function } return makeThreadWriter({ - filePath: path.join(path.dirname(filePath), `${threadSegment}.log`), + filePath: NodePath.join(NodePath.dirname(filePath), `${threadSegment}.log`), maxBytes, maxFiles, batchWindowMs, diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index bfd5ae25755..c871e3c2fc4 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -26,13 +26,13 @@ import { ServerConfig } from "../../config.ts"; import { makeGrokAdapter } from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const mockAgentCommand = process.execPath; async function makeMockGrokWrapper(extraEnv?: Record) { - const dir = await mkdtemp(path.join(os.tmpdir(), "grok-acp-mock-")); - const wrapperPath = path.join(dir, "fake-grok.sh"); + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-grok.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) .join("\n"); @@ -40,8 +40,8 @@ async function makeMockGrokWrapper(extraEnv?: Record) { ${envExports} exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" `; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); return wrapperPath; } @@ -51,7 +51,7 @@ function waitForFileContent(filePath: string, attempts = 40): Effect.Effect readFile(filePath, "utf8")).pipe( + const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( Effect.orElseSucceed(() => ""), ); if (raw.trim().length > 0) { @@ -64,7 +64,7 @@ function waitForFileContent(filePath: string, attempts = 40): Effect.Effect line.trim()) @@ -149,9 +149,9 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-session-close"); const tempDir = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), "grok-adapter-exit-log-")), + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-adapter-exit-log-")), ); - const exitLogPath = path.join(tempDir, "exit.log"); + const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ @@ -227,8 +227,10 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { it.effect("responds to ACP approvals using provider-supplied option ids", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-custom-approval-option-id"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "grok-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 778dd2c6e0c..8e6076a2618 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -27,7 +27,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -41,7 +41,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, @@ -102,7 +102,7 @@ interface GrokSessionContext { readonly acpSessionId: string; session: ProviderSession; readonly scope: Scope.Closeable; - readonly acp: AcpSessionRuntimeShape; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 75d0982565e..000243869c9 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -54,6 +54,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => Effect.gen(function* () { + const secretStderr = "broken grok install: secret-token-value"; const snapshot = yield* Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -62,7 +63,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { const grokPath = path.join(dir, "grok"); yield* fs.writeFileString( grokPath, - ["#!/bin/sh", 'printf "%s\\n" "broken grok install" >&2', "exit 2", ""].join("\n"), + ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), ); yield* fs.chmod(grokPath, 0o755); @@ -75,7 +76,8 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.enabled).toBe(true); expect(snapshot.installed).toBe(true); expect(snapshot.status).toBe("error"); - expect(snapshot.message).toContain("broken grok install"); + expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 35611398b4b..cf5d5ad9c8d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -6,7 +6,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -19,7 +19,6 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildServerProvider, - detailFromResult, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -195,6 +194,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func if (Result.isFailure(versionResult)) { const error = versionResult.failure; + yield* Effect.logWarning("Grok CLI health check failed.", { + errorTag: error._tag, + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -207,7 +209,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Grok CLI (`grok`) is not installed or not on PATH." - : `Failed to execute Grok CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : "Failed to execute Grok CLI health check.", }, }); } @@ -231,7 +233,11 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func const versionOutput = versionResult.success.value; const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); if (versionOutput.code !== 0) { - const detail = detailFromResult(versionOutput); + yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -242,9 +248,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func version, status: "error", auth: { status: "unknown" }, - message: detail - ? `Grok CLI is installed but failed to run. ${detail}` - : "Grok CLI is installed but failed to run.", + message: "Grok CLI is installed but failed to run.", }, }); } @@ -254,8 +258,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func Effect.exit, ); if (Exit.isFailure(discoveryExit)) { - const detail = Cause.pretty(discoveryExit.cause); - yield* Effect.logWarning("Grok ACP model discovery failed", { cause: detail }); + yield* Effect.logWarning("Grok ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -266,7 +271,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func version, status: "error", auth: { status: "unknown" }, - message: `Grok CLI is installed but ACP startup failed. ${detail}`, + message: "Grok CLI is installed but ACP startup failed. Check server logs for details.", }, }); } @@ -311,17 +316,20 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func export const enrichGrokSnapshot = (input: { readonly snapshot: ServerProvider; readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; readonly httpClient: HttpClient.HttpClient; }): Effect.Effect => { const { snapshot, publishSnapshot } = input; - return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe( + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), Effect.catchCause((cause) => Effect.logWarning("Grok version advisory enrichment failed", { - cause: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }), ), Effect.asVoid, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3f483d8fd7e..d0475e25284 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Context from "effect/Context"; @@ -238,11 +238,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { runtimeMode: "full-access", }); - assert.equal(session.provider, "opencode"); - assert.equal(session.threadId, "thread-opencode"); - assert.deepEqual(runtimeMock.state.startCalls, []); - assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); - assert.deepEqual(runtimeMock.state.authHeaders, [ + NodeAssert.equal(session.provider, "opencode"); + NodeAssert.equal(session.threadId, "thread-opencode"); + NodeAssert.deepEqual(runtimeMock.state.startCalls, []); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(runtimeMock.state.authHeaders, [ `Basic ${btoa("opencode:secret-password")}`, ]); }), @@ -259,8 +259,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(asThreadId("thread-opencode")); - assert.deepEqual(runtimeMock.state.startCalls, []); - assert.deepEqual( + NodeAssert.deepEqual(runtimeMock.state.startCalls, []); + NodeAssert.deepEqual( runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), true, ); @@ -286,7 +286,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(threadId); const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - assert.deepEqual( + NodeAssert.deepEqual( events.map((event) => event.type), ["session.started", "thread.started", "session.exited"], ); @@ -316,11 +316,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* Effect.exit(adapter.stopAll()); const sessions = yield* adapter.listSessions(); - assert.deepEqual(runtimeMock.state.closeCalls, [ + NodeAssert.deepEqual(runtimeMock.state.closeCalls, [ "http://127.0.0.1:9999", "http://127.0.0.1:9999", ]); - assert.deepEqual(sessions, []); + NodeAssert.deepEqual(sessions, []); }), ); @@ -348,7 +348,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { scopeClosed = true; const exit = yield* Fiber.await(eventsFiber).pipe(Effect.timeout("1 second")); - assert.equal(Exit.hasInterrupts(exit), true); + NodeAssert.equal(Exit.hasInterrupts(exit), true); } finally { if (!scopeClosed) { yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); @@ -379,19 +379,19 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { .pipe(Effect.flip); const sessions = yield* adapter.listSessions(); - assert.equal(error._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); if (error._tag !== "ProviderAdapterRequestError") { throw new Error("Unexpected error type"); } - assert.equal(error.detail, "prompt failed"); - assert.equal( + NodeAssert.equal(error.detail, "prompt failed"); + NodeAssert.equal( error.message, "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", ); - assert.equal(sessions.length, 1); - assert.equal(sessions[0]?.status, "ready"); - assert.equal(sessions[0]?.activeTurnId, undefined); - assert.equal(sessions[0]?.lastError, "prompt failed"); + NodeAssert.equal(sessions.length, 1); + NodeAssert.equal(sessions[0]?.status, "ready"); + NodeAssert.equal(sessions[0]?.activeTurnId, undefined); + NodeAssert.equal(sessions[0]?.lastError, "prompt failed"); }), ); @@ -424,13 +424,13 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { model: "openai/gpt-5", }, }); - assert.equal(String(steeredTurn.turnId), String(turn.turnId)); + NodeAssert.equal(String(steeredTurn.turnId), String(turn.turnId)); const sessions = yield* adapter.listSessions(); const session = sessions.find((entry) => entry.threadId === threadId); - assert.equal(session?.status, "running"); - assert.equal(String(session?.activeTurnId), String(turn.turnId)); - assert.equal(runtimeMock.state.promptCalls.length, 2); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); }), ); @@ -466,11 +466,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { .pipe(Effect.flip); // The original turn keeps running — only the steer prompt failed. - assert.equal(error._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); const sessions = yield* adapter.listSessions(); const session = sessions.find((entry) => entry.threadId === threadId); - assert.equal(session?.status, "running"); - assert.equal(String(session?.activeTurnId), String(turn.turnId)); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); }), ); @@ -508,7 +508,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ), }); - assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { sessionID: "http://127.0.0.1:9999/session", model: { providerID: "anthropic", @@ -552,7 +552,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { input: "Fix it", }); - assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { sessionID: "http://127.0.0.1:9999/session", model: { providerID: "anthropic", @@ -596,15 +596,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }) .pipe(Effect.flip); - assert.equal(error._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(error._tag, "ProviderAdapterValidationError"); if (error._tag !== "ProviderAdapterValidationError") { throw new Error("Unexpected error type"); } - assert.equal( + NodeAssert.equal( error.issue, "OpenCode model selection is bound to instance 'opencode', expected 'opencode_zen'.", ); - assert.deepEqual(runtimeMock.state.promptCalls, []); + NodeAssert.deepEqual(runtimeMock.state.promptCalls, []); }).pipe(Effect.provide(adapterLayer)); }); @@ -631,10 +631,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const snapshot = yield* adapter.rollbackThread(threadId, 2); - assert.deepEqual(runtimeMock.state.revertCalls, [ + NodeAssert.deepEqual(runtimeMock.state.revertCalls, [ { sessionID: "http://127.0.0.1:9999/session" }, ]); - assert.deepEqual(snapshot.turns, []); + NodeAssert.deepEqual(snapshot.turns, []); }), ); @@ -644,11 +644,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); - assert.deepEqual( + NodeAssert.deepEqual( [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], ["Hello", "lo world", ""], ); - assert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); }), ); @@ -721,14 +721,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); const deltas = events.filter((event) => event.type === "content.delta"); - assert.deepEqual( + NodeAssert.deepEqual( deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : "")), ["A B", "Bonus"], ); - assert.equal(events.at(-1)?.type, "item.completed"); + NodeAssert.equal(events.at(-1)?.type, "item.completed"); const completed = events.at(-1); if (completed?.type === "item.completed") { - assert.equal(completed.payload.detail, "A BBonus"); + NodeAssert.equal(completed.payload.detail, "A BBonus"); } }), ); @@ -820,27 +820,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { return started; }).pipe(Effect.provide(adapterLayer)); - assert.equal(session.threadId, "thread-native-log"); - assert.equal(nativeEvents.length, 1); - assert.equal( + NodeAssert.equal(session.threadId, "thread-native-log"); + NodeAssert.equal(nativeEvents.length, 1); + NodeAssert.equal( nativeEvents.some((record) => record.event?.provider === "opencode"), true, ); - assert.equal( + NodeAssert.equal( nativeEvents.some( (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", ), true, ); - assert.equal( + NodeAssert.equal( nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), true, ); - assert.equal( + NodeAssert.equal( nativeEvents.some((record) => record.event?.type === "message.updated"), true, ); - assert.equal( + NodeAssert.equal( nativeThreadIds.every((threadId) => threadId === "thread-native-log"), true, ); @@ -911,9 +911,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }; }).pipe(Effect.provide(adapterLayer)); - assert.equal(sessions.length, 1); - assert.equal(sessions[0]?.threadId, "thread-native-log-failure"); - assert.deepEqual(closeCallsDuringRun, []); + NodeAssert.equal(sessions.length, 1); + NodeAssert.equal(sessions[0]?.threadId, "thread-native-log-failure"); + NodeAssert.deepEqual(closeCallsDuringRun, []); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index eac9f0b43fb..b0e785512dc 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -122,9 +122,12 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, false); - assert.equal(snapshot.message, "OpenCode CLI (`opencode`) is not installed or not on PATH."); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.equal( + snapshot.message, + "OpenCode CLI (`opencode`) is not installed or not on PATH.", + ); }), ); @@ -133,9 +136,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); }), ); @@ -174,20 +177,20 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); - assert.ok(model); + NodeAssert.ok(model); const variantDescriptor = model.capabilities?.optionDescriptors?.find( (descriptor) => descriptor.id === "variant" && descriptor.type === "select", ); - assert.ok(variantDescriptor && variantDescriptor.type === "select"); - assert.equal( + NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); + NodeAssert.equal( variantDescriptor.options.find((option) => option.isDefault === true)?.id, "medium", ); const agentDescriptor = model.capabilities?.optionDescriptors?.find( (descriptor) => descriptor.id === "agent" && descriptor.type === "select", ); - assert.ok(agentDescriptor && agentDescriptor.type === "select"); - assert.equal( + NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); + NodeAssert.equal( agentDescriptor.options.find((option) => option.isDefault === true)?.id, "build", ); @@ -198,7 +201,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - assert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.closeCalls, 1); }), ); }); @@ -215,9 +218,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i process.cwd(), ); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal( + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( snapshot.message, "OpenCode server rejected authentication. Check the server URL and password.", ); @@ -237,9 +240,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i process.cwd(), ); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal( + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( snapshot.message, "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 7fb545b2bed..c4145ecf1a0 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -10,16 +10,16 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; -import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; -import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; -import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; +import type * as CodexAdapter from "../Services/CodexAdapter.ts"; +import type * as CursorAdapter from "../Services/CursorAdapter.ts"; +import type * as OpenCodeAdapter from "../Services/OpenCodeAdapter.ts"; +import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; +import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; -import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; +import type * as TextGeneration from "../../textGeneration/TextGeneration.ts"; +import * as ProviderAdapterRegistryLayer from "./ProviderAdapterRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; const CODEX_DRIVER = ProviderDriverKind.make("codex"); @@ -27,7 +27,7 @@ const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); -const fakeCodexAdapter: CodexAdapterShape = { +const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = { provider: CODEX_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -44,7 +44,7 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; -const fakeClaudeAdapter: ClaudeAdapterShape = { +const fakeClaudeAdapter: ClaudeAdapter.ClaudeAdapterShape = { provider: CLAUDE_AGENT_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -61,7 +61,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; -const fakeOpenCodeAdapter: OpenCodeAdapterShape = { +const fakeOpenCodeAdapter: OpenCodeAdapter.OpenCodeAdapterShape = { provider: OPENCODE_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -78,7 +78,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { streamEvents: Stream.empty, }; -const fakeCursorAdapter: CursorAdapterShape = { +const fakeCursorAdapter: CursorAdapter.CursorAdapterShape = { provider: CURSOR_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -124,7 +124,7 @@ const makeFakeInstance = ( streamChanges: Stream.empty, }, adapter, - textGeneration: {} as unknown as TextGenerationShape, + textGeneration: {} as unknown as TextGeneration.TextGeneration["Service"], }; }; @@ -135,7 +135,7 @@ const fakeInstances: ReadonlyArray = [ makeFakeInstance("cursor", fakeCursorAdapter), ]; -const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { +const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, { getInstance: (instanceId) => Effect.succeed(fakeInstances.find((instance) => instance.instanceId === instanceId)), listInstances: Effect.succeed(fakeInstances), @@ -147,14 +147,17 @@ const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { }); const layer = Layer.mergeAll( - Layer.provide(ProviderAdapterRegistryLive, fakeInstanceRegistryLayer), + Layer.provide( + ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive, + fakeInstanceRegistryLayer, + ), NodeServices.layer, ); it.layer(layer)("ProviderAdapterRegistryLive", (it) => { it("resolves adapters and routing metadata from provider instances", () => Effect.gen(function* () { - const registry = yield* ProviderAdapterRegistry; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER); const adapter = yield* registry.getByInstance(claudeInstanceId); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 4e43e04cb7c..0fd88b4262a 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -114,11 +114,7 @@ export const deriveProviderInstanceConfigMap = ( * configs, so the only way the watcher could fail is a settings stream * tear-down, which logs and exits cleanly. */ -const SettingsWatcherLive: Layer.Layer< - never, - never, - ProviderInstanceRegistryMutator | ServerSettingsService -> = Layer.effectDiscard( +const SettingsWatcherLive = Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator; const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index f2c5892a2c6..dbfa7faffea 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -39,6 +39,7 @@ import * as Layer from "effect/Layer"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; @@ -107,6 +108,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { prefix: "provider-instance-registry-test", }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); @@ -244,6 +246,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { prefix: "provider-instance-registry-all-drivers-test", }).pipe( Layer.provideMerge(infraLayer), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5fe0f903686..b3ab1145495 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -32,8 +32,8 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; -import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; -import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { haveProvidersChanged, @@ -42,12 +42,12 @@ import { ProviderRegistryLive, selectProvidersByKind, } from "./ProviderRegistry.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; -import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; -import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -294,11 +294,11 @@ function makeMutableServerSettingsService( get streamChanges() { return Stream.fromPubSub(changes); }, - } satisfies ServerSettingsShape; + } satisfies ServerSettingsModule.ServerSettingsService["Service"]; }); } -it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), TestHttpClientLive))( +it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))( "ProviderRegistry", (it) => { describe("checkCodexProviderStatus", () => { @@ -636,14 +636,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( @@ -658,7 +661,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ), ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); assert.strictEqual(yield* Ref.get(refreshCalls), 0); }).pipe(Effect.provide(runtimeServices)); @@ -786,16 +789,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( @@ -811,8 +817,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - const config = yield* ServerConfig; + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; const filePath = yield* resolveProviderStatusCachePath({ cacheDir: config.providerStatusCacheDir, instanceId: cursorInstanceId, @@ -880,16 +886,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( @@ -905,7 +914,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); @@ -975,25 +984,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T const instancesRef = yield* Ref.make>([codexInstance]); const failNextList = yield* Ref.make(false); const wait = () => Effect.yieldNow; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === instanceId), + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === instanceId), + ), ), - ), - listInstances: Effect.gen(function* () { - const shouldFail = yield* Ref.get(failNextList); - if (shouldFail) { - yield* Ref.set(failNextList, false); - return yield* Effect.die(new Error("simulated registry list failure")); - } - return yield* Ref.get(instancesRef); - }), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), - }); + listInstances: Effect.gen(function* () { + const shouldFail = yield* Ref.get(failNextList); + if (shouldFail) { + yield* Ref.set(failNextList, false); + return yield* Effect.die(new Error("simulated registry list failure")); + } + return yield* Ref.get(instancesRef); + }), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }, + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( @@ -1009,7 +1021,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); yield* Ref.set(failNextList, true); @@ -1092,15 +1104,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will // genuinely spawn a subprocess. The missing-binary ENOENT is @@ -1112,7 +1131,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; let providers = yield* registry.getProviders; for ( let attempts = 0; @@ -1177,15 +1196,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { spawnedCommands.push((command as { readonly command: string }).command); @@ -1199,7 +1225,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the // snapshot should be `status: "error"`. @@ -1291,15 +1317,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( @@ -1307,7 +1340,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; const providers = yield* registry.getProviders; const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); @@ -1345,15 +1378,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { if (command === "agent") { @@ -1380,13 +1420,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); const runtimeServices = yield* Layer.build( Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), providerRegistryLayer, ), ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; const providers = yield* registry.getProviders; const cursorProvider = providers.find( (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), @@ -1835,14 +1875,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), ); - it.effect("returns error when version check fails with non-zero exit code", () => - Effect.gen(function* () { + it.effect("returns error when version check fails with non-zero exit code", () => { + const secretStderr = "Something went wrong: secret-token-value"; + return Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities(), ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); + assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); + assert.ok(!(status.message ?? "").includes(secretStderr)); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -1850,14 +1893,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T if (joined === "--version") return { stdout: "", - stderr: "Something went wrong", + stderr: secretStderr, code: 1, }; throw new Error(`Unexpected args: ${joined}`); }), ), - ), - ); + ); + }); it.effect("returns warning when the Claude initialization result is unavailable", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 6a72bf69941..ccbbce1759f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import type { ProviderApprovalDecision, @@ -43,27 +43,23 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import { - ProviderAdapterRegistry, - type ProviderAdapterRegistryShape, -} from "../Services/ProviderAdapterRegistry.ts"; -import { ProviderService } from "../Services/ProviderService.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; +import * as ProviderService from "../Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "./ProviderService.ts"; -import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; -const defaultServerSettingsLayer = ServerSettingsService.layerTest(); +const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); @@ -281,8 +277,11 @@ function makeProviderServiceLayer() { [ProviderDriverKind.make("cursor")]: cursor.adapter, }); - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -294,7 +293,12 @@ function makeProviderServiceLayer() { Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ), directoryLayer, @@ -326,8 +330,11 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => const registry = makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter, }); - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -337,7 +344,12 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ), directoryLayer, runtimeRepositoryLayer, @@ -346,7 +358,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => const scope = yield* Scope.make(); const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); - yield* ProviderService.pipe(Effect.provide(runtimeServices)); + yield* ProviderService.ProviderService.pipe(Effect.provide(runtimeServices)); const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit); assert.equal(Exit.isSuccess(closeExit), true); @@ -362,7 +374,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () [CODEX_DRIVER]: codex.adapter, [CLAUDE_AGENT_DRIVER]: claude.adapter, }); - const registry: ProviderAdapterRegistryShape = { + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { ...registryBase, getInstanceInfo: (instanceId) => instanceId === claudeAgentInstanceId @@ -378,8 +390,11 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () }) : registryBase.getInstanceInfo(instanceId), }; - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -388,12 +403,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const failure = yield* Effect.flip( Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-disabled"), { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -420,7 +440,7 @@ it.effect( new ProviderUnsupportedError({ provider: driverKind, }); - const registry: ProviderAdapterRegistryShape = { + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { getByInstance: (requestedInstanceId) => requestedInstanceId === instanceId ? Effect.succeed(codex.adapter) @@ -445,15 +465,18 @@ it.effect( PubSub.subscribe(pubsub), ), }; - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const serverSettingsLayer = ServerSettingsService.layerTest({ + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest({ providers: { codex: { enabled: false, }, }, }); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe( @@ -464,11 +487,16 @@ it.effect( Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const session = yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-enabled-custom"), { provider: driverKind, providerInstanceId: instanceId, @@ -491,7 +519,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance new ProviderUnsupportedError({ provider: ProviderDriverKind.make("codex"), }); - const registry: ProviderAdapterRegistryShape = { + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { getByInstance: (requestedInstanceId) => requestedInstanceId === instanceId ? Effect.succeed(codex.adapter) @@ -516,8 +544,11 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance PubSub.subscribe(pubsub), ), }; - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -526,12 +557,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const failure = yield* Effect.flip( Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-disabled-instance"), { provider: ProviderDriverKind.make("codex"), providerInstanceId: instanceId, @@ -557,7 +593,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se const registry = makeAdapterRegistryMock({ [ProviderDriverKind.make("codex")]: codex.adapter, }); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -572,15 +608,20 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se close: () => Effect.void, }, }).pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); yield* Effect.gen(function* () { - yield* ProviderService; + yield* ProviderService.ProviderService; yield* advanceTestClock(10); codex.emit({ eventId: asEventId("evt-canonical-thread-segment"), @@ -603,8 +644,8 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-service-")); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const codex = makeFakeCodexAdapter(); const registry = makeAdapterRegistryMock({ @@ -612,13 +653,13 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); yield* Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, @@ -627,23 +668,28 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }).pipe(Effect.provide(directoryLayer)); const providerLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); - yield* ProviderService.pipe(Effect.provide(providerLayer)); + yield* ProviderService.ProviderService.pipe(Effect.provide(providerLayer)); const persistedProvider = yield* Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; return yield* directory.getProvider(asThreadId("thread-stale")); }).pipe(Effect.provide(directoryLayer)); assert.equal(persistedProvider, "codex"); const runtime = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale"), }); @@ -660,7 +706,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }).pipe(Effect.provide(persistenceLayer)); assert.equal(legacyTableRows.length, 0); - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -668,10 +714,12 @@ it.effect( "ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-provider-service-restart-"), + ); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -684,11 +732,18 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), + ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const updatedResumeCursor = { threadId: asThreadId("thread-1"), @@ -698,7 +753,7 @@ it.effect( }; const startedSession = yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const threadId = asThreadId("thread-1"); const session = yield* provider.startSession(threadId, { provider: ProviderDriverKind.make("codex"), @@ -717,7 +772,7 @@ it.effect( }).pipe(Effect.provide(firstProviderLayer)); const persistedAfterStopAll = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; return yield* repository.getByThreadId({ threadId: startedSession.threadId, }); @@ -736,18 +791,25 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), + ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); secondCodex.startSession.mockClear(); secondCodex.rollbackThread.mockClear(); yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.rollbackConversation({ threadId: startedSession.threadId, numTurns: 1, @@ -774,14 +836,14 @@ it.effect( assert.equal(typeof rollbackCall?.[0], "string"); assert.equal(rollbackCall?.[1], 1); - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); routing.layer("ProviderServiceLive routing", (it) => { it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -867,7 +929,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -908,8 +970,8 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("preserves the persisted binding when stopping a session", () => Effect.gen(function* () { - const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { provider: ProviderDriverKind.make("codex"), @@ -960,7 +1022,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-claude"), { provider: ProviderDriverKind.make("claudeAgent"), @@ -989,8 +1051,8 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("dies when an active session conflicts with its persisted binding", () => Effect.gen(function* () { - const provider = yield* ProviderService; - const directory = yield* ProviderSessionDirectory; + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const threadId = asThreadId("thread-binding-mismatch"); yield* provider.startSession(threadId, { @@ -1020,7 +1082,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("stops stale sessions in other providers after a successful replacement start", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const threadId = asThreadId("thread-provider-replacement"); const codexSession = yield* provider.startSession(threadId, { @@ -1059,7 +1121,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("recovers stale sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -1100,7 +1162,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("recovers stale claudeAgent sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { provider: ProviderDriverKind.make("claudeAgent"), @@ -1153,7 +1215,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("lists no sessions after adapter runtime clears", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -1178,8 +1240,8 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("persists runtime status transitions in provider_session_runtime", () => Effect.gen(function* () { - const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = asThreadId("thread-runtime-status"); const session = yield* provider.startSession(threadId, { @@ -1223,10 +1285,12 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("reuses persisted resume cursor when startSession is called after a restart", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-start-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-provider-service-start-"), + ); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -1238,15 +1302,22 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), + ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const initial = yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-claude-start"), { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -1257,7 +1328,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }).pipe(Effect.provide(firstProviderLayer)); yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.listSessions(); }).pipe(Effect.provide(firstProviderLayer)); @@ -1269,17 +1340,24 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), + ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); secondClaude.startSession.mockClear(); yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.startSession(initial.threadId, { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -1305,7 +1383,7 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(startPayload.threadId, initial.threadId); } - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -1313,10 +1391,12 @@ routing.layer("ProviderServiceLive routing", (it) => { "reuses persisted cwd when startSession resumes a claude session without cwd input", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-provider-service-cwd-"), + ); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -1328,15 +1408,22 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), + ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const initial = yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-claude-cwd"), { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -1354,17 +1441,24 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), + ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); secondClaude.startSession.mockClear(); yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.startSession(initial.threadId, { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -1389,7 +1483,7 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(startPayload.threadId, initial.threadId); } - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); }); @@ -1398,7 +1492,7 @@ const fanout = makeProviderServiceLayer(); fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("fans out adapter turn completion events", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, @@ -1444,7 +1538,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("fans out canonical runtime events in emission order", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-seq"), { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, @@ -1500,7 +1594,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, @@ -1572,7 +1666,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("records provider metrics with the routed provider label", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-metrics"), { provider: ProviderDriverKind.make("claudeAgent"), @@ -1650,7 +1744,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { "records sendTurn metrics with the resolved provider when modelSelection is omitted", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-send-metrics"), { provider: ProviderDriverKind.make("claudeAgent"), @@ -1691,7 +1785,7 @@ const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { it.effect("rejects session starts without an explicit provider instance id", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; validation.codex.startSession.mockClear(); const failure = yield* Effect.flip( @@ -1710,7 +1804,7 @@ validation.layer("ProviderServiceLive validation", (it) => { it.effect("rejects mismatched provider kind and provider instance id", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; validation.codex.startSession.mockClear(); validation.claude.startSession.mockClear(); @@ -1735,7 +1829,7 @@ validation.layer("ProviderServiceLive validation", (it) => { it.effect("returns ProviderValidationError for invalid input payloads", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const failure = yield* Effect.result( provider.startSession(asThreadId("thread-validation"), { @@ -1760,8 +1854,8 @@ validation.layer("ProviderServiceLive validation", (it) => { it.effect("accepts startSession when adapter has not emitted provider thread id yet", () => Effect.gen(function* () { - const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 72d6f305ca9..603ffeb952f 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -25,7 +25,7 @@ import { type ProviderRuntimeEvent, type ProviderSession, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -48,15 +48,12 @@ import { } from "../../observability/Metrics.ts"; import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; -import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; -import { - ProviderSessionDirectory, - type ProviderRuntimeBinding, -} from "../Services/ProviderSessionDirectory.ts"; +import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; +import * as ProviderService from "../Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; -import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; -import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; const isModelSelection = Schema.is(ModelSelection); @@ -70,6 +67,9 @@ export interface ProviderServiceLiveOptions { readonly canonicalEventLogger?: EventNdjsonLogger; } +type ProviderServiceMethod = + ProviderService.ProviderService["Service"][Name]; + const ProviderRollbackConversationInput = Schema.Struct({ threadId: ThreadId, numTurns: NonNegativeInt, @@ -142,7 +142,7 @@ function toRuntimePayloadFromSession( } function readPersistedModelSelection( - runtimePayload: ProviderRuntimeBinding["runtimePayload"], + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], ): ModelSelection | undefined { if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { return undefined; @@ -152,7 +152,7 @@ function readPersistedModelSelection( } function readPersistedCwd( - runtimePayload: ProviderRuntimeBinding["runtimePayload"], + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], ): string | undefined { if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { return undefined; @@ -203,16 +203,16 @@ const correlateRuntimeEventWithInstance = ( const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { - const analytics = yield* Effect.service(AnalyticsService); - const eventLoggers = yield* ProviderEventLoggers; + const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; // Options-provided logger wins (test overrides); otherwise we take whatever // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical // log writer is attached", which downstream code already handles as a // no-op. const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical; - const registry = yield* ProviderAdapterRegistry; - const directory = yield* ProviderSessionDirectory; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => @@ -354,7 +354,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ).pipe(Effect.forkScoped); const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { - readonly binding: ProviderRuntimeBinding; + readonly binding: ProviderSessionDirectory.ProviderRuntimeBinding; readonly operation: string; }) { const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding); @@ -520,7 +520,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); - const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")( + const startSession: ProviderServiceMethod<"startSession"> = Effect.fn("startSession")( function* (threadId, rawInput) { const parsed = yield* decodeInputOrValidationError({ operation: "ProviderService.startSession", @@ -643,7 +643,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const sendTurn: ProviderServiceShape["sendTurn"] = Effect.fn("sendTurn")(function* (rawInput) { + const sendTurn: ProviderServiceMethod<"sendTurn"> = Effect.fn("sendTurn")(function* (rawInput) { const parsed = yield* decodeInputOrValidationError({ operation: "ProviderService.sendTurn", schema: ProviderSendTurnInput, @@ -718,7 +718,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); - const interruptTurn: ProviderServiceShape["interruptTurn"] = Effect.fn("interruptTurn")( + const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ operation: "ProviderService.interruptTurn", @@ -755,7 +755,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const sendGoalRequest: ProviderServiceShape["sendGoalRequest"] = Effect.fn("sendGoalRequest")( + const sendGoalRequest: ProviderServiceMethod<"sendGoalRequest"> = Effect.fn("sendGoalRequest")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ operation: "ProviderService.sendGoalRequest", @@ -799,7 +799,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const respondToRequest: ProviderServiceShape["respondToRequest"] = Effect.fn("respondToRequest")( + const respondToRequest: ProviderServiceMethod<"respondToRequest"> = Effect.fn("respondToRequest")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ operation: "ProviderService.respondToRequest", @@ -837,7 +837,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const respondToUserInput: ProviderServiceShape["respondToUserInput"] = Effect.fn( + const respondToUserInput: ProviderServiceMethod<"respondToUserInput"> = Effect.fn( "respondToUserInput", )(function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -871,7 +871,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); - const stopSession: ProviderServiceShape["stopSession"] = Effect.fn("stopSession")( + const stopSession: ProviderServiceMethod<"stopSession"> = Effect.fn("stopSession")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ operation: "ProviderService.stopSession", @@ -919,7 +919,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")( + const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")( function* () { const currentAdapters = yield* getAdapterEntries; const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => @@ -940,13 +940,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( (threadId) => directory .getBinding(threadId) - .pipe(Effect.orElseSucceed(() => Option.none())), + .pipe( + Effect.orElseSucceed(() => + Option.none(), + ), + ), { concurrency: "unbounded" }, ), ), - Effect.orElseSucceed(() => [] as Array>), + Effect.orElseSucceed( + () => [] as Array>, + ), ); - const bindingsByThreadId = new Map(); + const bindingsByThreadId = new Map< + ThreadId, + ProviderSessionDirectory.ProviderRuntimeBinding + >(); for (const bindingOption of persistedBindings) { const binding = Option.getOrUndefined(bindingOption); if (binding) { @@ -997,13 +1006,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const getCapabilities: ProviderServiceShape["getCapabilities"] = (instanceId) => + const getCapabilities: ProviderServiceMethod<"getCapabilities"> = (instanceId) => registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities)); - const getInstanceInfo: ProviderServiceShape["getInstanceInfo"] = (instanceId) => + const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) => registry.getInstanceInfo(instanceId); - const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn( + const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn( "rollbackConversation", )(function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -1097,7 +1106,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.addFinalizer(() => runStopAll().pipe( Effect.catchCause((cause) => - Effect.logWarning("failed to stop provider service", { cause: Cause.pretty(cause) }), + Effect.logWarning("failed to stop provider service", { + errorTag: causeErrorTag(cause), + }), ), ), ); @@ -1117,14 +1128,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. - get streamEvents(): ProviderServiceShape["streamEvents"] { + get streamEvents(): ProviderServiceMethod<"streamEvents"> { return Stream.fromPubSub(runtimeEventPubSub); }, - } satisfies ProviderServiceShape; + } satisfies ProviderService.ProviderService["Service"]; }); -export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService()); +export const ProviderServiceLive = Layer.effect( + ProviderService.ProviderService, + makeProviderService(), +); export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) { - return Layer.effect(ProviderService, makeProviderService(options)); + return Layer.effect(ProviderService.ProviderService, makeProviderService(options)); } diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index f9793ca9d1f..079b7f10ebf 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; @@ -16,15 +16,12 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; function makeDirectoryLayer(persistenceLayer: Layer.Layer) { - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( - Layer.provide(persistenceLayer), - ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), @@ -36,7 +33,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initialThreadId = ThreadId.make("thread-1"); @@ -83,7 +80,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-runtime"); @@ -128,7 +125,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const olderThreadId = ThreadId.make("thread-runtime-older"); const newerThreadId = ThreadId.make("thread-runtime-newer"); @@ -202,7 +199,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-provider-change"); yield* runtimeRepository.upsert({ @@ -232,8 +229,8 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); const threadId = ThreadId.make("thread-restart"); @@ -269,6 +266,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(legacyTableRows.length, 0); }).pipe(Effect.provide(directoryLayer)); - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 0508f6c8cb3..23075bd9a06 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -5,8 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, @@ -59,7 +58,7 @@ function mergeRuntimePayload( } function toRuntimeBinding( - runtime: ProviderSessionRuntime, + runtime: ProviderSessionRuntime.ProviderSessionRuntime, operation: string, ): Effect.Effect { return decodeProviderDriverKind(runtime.providerName, operation).pipe( @@ -85,7 +84,7 @@ function toRuntimeBinding( } const makeProviderSessionDirectory = Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const getBinding = (threadId: ThreadId) => repository.getByThreadId({ threadId }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 0926aea95dd..fd00d11a86b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -19,8 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderValidationError } from "../Errors.ts"; import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; @@ -119,7 +118,7 @@ function makeReadModel( describe("ProviderSessionReaper", () => { let runtime: ManagedRuntime.ManagedRuntime< - ProviderSessionReaper | ProviderSessionRuntimeRepository, + ProviderSessionReaper | ProviderSessionRuntime.ProviderSessionRuntimeRepository, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -177,7 +176,7 @@ describe("ProviderSessionReaper", () => { streamEvents: Stream.empty, }; - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( @@ -239,7 +238,9 @@ describe("ProviderSessionReaper", () => { }, ]), }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -287,7 +288,9 @@ describe("ProviderSessionReaper", () => { }, ]), }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -334,7 +337,9 @@ describe("ProviderSessionReaper", () => { }, ]), }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -381,7 +386,9 @@ describe("ProviderSessionReaper", () => { }, ]), }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -450,7 +457,9 @@ describe("ProviderSessionReaper", () => { ) : Effect.void, }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -531,7 +540,9 @@ describe("ProviderSessionReaper", () => { ? Effect.die(new Error("simulated stop defect")) : Effect.void, }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 3a57f374de4..c738882c23a 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -30,7 +30,7 @@ import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; -import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; +import type * as TextGeneration from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; @@ -70,7 +70,7 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; - readonly textGeneration: TextGenerationShape; + readonly textGeneration: TextGeneration.TextGeneration["Service"]; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index f2e286c589c..5533a04bc83 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -10,19 +10,19 @@ import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; -import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const mockAgentCommand = "node"; const mockAgentArgs = [mockAgentPath]; describe("AcpSessionRuntime", () => { it.effect("merges custom initialize client capabilities into the ACP handshake", () => { - const requestEvents: Array = []; + const requestEvents: Array = []; return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); const initializeStarted = requestEvents.find( @@ -64,7 +64,7 @@ describe("AcpSessionRuntime", () => { it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); expect(started.initializeResult).toMatchObject({ protocolVersion: 1 }); @@ -115,7 +115,7 @@ describe("AcpSessionRuntime", () => { it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); const promptResult = yield* runtime.prompt({ @@ -176,7 +176,7 @@ describe("AcpSessionRuntime", () => { it.effect("suppresses generic placeholder tool updates until completion", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); const promptResult = yield* runtime.prompt({ @@ -213,9 +213,9 @@ describe("AcpSessionRuntime", () => { ); it.effect("logs ACP requests from the shared runtime", () => { - const requestEvents: Array = []; + const requestEvents: Array = []; return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); yield* runtime.setModel("composer-2"); @@ -265,9 +265,9 @@ describe("AcpSessionRuntime", () => { }); it.effect("skips no-op session config writes when the requested value is already active", () => { - const requestEvents: Array = []; + const requestEvents: Array = []; return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); yield* runtime.setConfigOption("model", "default"); @@ -302,7 +302,7 @@ describe("AcpSessionRuntime", () => { it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { const protocolEvents: Array = []; return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); yield* runtime.prompt({ @@ -347,10 +347,10 @@ describe("AcpSessionRuntime", () => { }); it.effect("rejects invalid config option values before sending session/set_config_option", () => { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); - const requestLogPath = path.join(tempDir, "requests.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip); @@ -363,7 +363,7 @@ describe("AcpSessionRuntime", () => { expect(error.message).toContain("composer-2[fast=true]"); } - const recordedRequests = readFileSync(requestLogPath, "utf8") + const recordedRequests = NodeFS.readFileSync(requestLogPath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) @@ -392,7 +392,7 @@ describe("AcpSessionRuntime", () => { ), Effect.scoped, Effect.provide(NodeServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), ); }); }); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts new file mode 100644 index 00000000000..8c92d523aee --- /dev/null +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -0,0 +1,137 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Logger from "effect/Logger"; +import * as Schema from "effect/Schema"; +import * as AcpErrors from "effect-acp/errors"; + +import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; +import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; + +const nodeServicesIt = it.layer(NodeServices.layer); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +nodeServicesIt("ACP native logging", (it) => { + it.effect("records bounded request and protocol diagnostics without raw payloads", () => + Effect.gen(function* () { + const records: Array = []; + const nativeEventLogger: EventNdjsonLogger = { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + }); + const secret = "secret-token-value"; + const requestLogger = logger.requestLogger; + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(requestLogger); + assert.exists(protocolLogger); + if (!requestLogger || !protocolLogger) return; + + yield* requestLogger({ + method: "session/prompt", + payload: { prompt: secret, sessionId: secret }, + status: "failed", + cause: Cause.fail(AcpErrors.AcpRequestError.internalError(secret, { token: secret })), + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `{"token":"${secret}"}`, + }); + yield* protocolLogger({ + direction: "outgoing", + stage: "decoded", + payload: { + _tag: "Request", + tag: "session/prompt", + payload: { prompt: secret }, + }, + }); + + const serialized = encodeUnknownJson(records); + assert.notInclude(serialized, secret); + assert.include(serialized, '"method":"session/prompt"'); + assert.include(serialized, '"errorTag":"AcpRequestError"'); + assert.include(serialized, '"reasonCount":1'); + assert.include(serialized, '"valueType":"string"'); + assert.include(serialized, '"messageTag":"Request"'); + }), + ); + + it.effect("logs a structural tag when the native writer defects", () => { + const messages: Array = []; + const logCapture = Logger.make(({ message }) => { + if (Array.isArray(message)) { + messages.push(...message); + } else { + messages.push(message); + } + }); + const secret = "secret-writer-failure"; + + return Effect.gen(function* () { + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: () => Effect.die(new Error(secret)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + }); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + + const serialized = encodeUnknownJson(messages); + assert.notInclude(serialized, secret); + assert.include(serialized, '"errorTag":"Die"'); + assert.include(serialized, '"reasonCount":1'); + }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); + }); + + it.effect("preserves native writer interruption", () => + Effect.gen(function* () { + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: () => Effect.interrupt, + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + }); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + + const exit = yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }).pipe(Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.isTrue(Cause.hasInterruptsOnly(exit.cause)); + } + }), + ); +}); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 6146980e4fb..06bff3aa611 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -1,4 +1,5 @@ import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -6,15 +7,60 @@ import * as Effect from "effect/Effect"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; -import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; -function formatRequestLogPayload(event: AcpSessionRequestLogEvent) { +function structuralMethod(value: string): string { + return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; +} + +function summarizePayload(payload: unknown): Readonly> { + if (payload === null) return { valueType: "null" }; + if (typeof payload === "string") { + return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; + } + if (payload instanceof Uint8Array) { + return { valueType: "bytes", byteLength: payload.byteLength }; + } + if (Array.isArray(payload)) { + return { valueType: "array", itemCount: payload.length }; + } + if (typeof payload !== "object") { + return { valueType: typeof payload }; + } + + try { + const record = payload as Record; + return { + valueType: "object", + fieldCount: Object.keys(record).length, + ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), + ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}), + }; + } catch { + return { valueType: "object" }; + } +} + +function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { return { - method: event.method, + method: structuralMethod(event.method), status: event.status, - request: event.payload, - ...(event.result !== undefined ? { result: event.result } : {}), - ...(event.cause !== undefined ? { cause: Cause.pretty(event.cause) } : {}), + request: summarizePayload(event.payload), + ...(event.result !== undefined ? { result: summarizePayload(event.result) } : {}), + ...(event.cause !== undefined + ? { + errorTag: causeErrorTag(event.cause), + reasonCount: event.cause.reasons.length, + } + : {}), + }; +} + +function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) { + return { + direction: event.direction, + stage: event.stage, + payload: summarizePayload(event.payload), }; } @@ -24,7 +70,7 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" readonly nativeEventLogger: EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; readonly threadId: ThreadId; - }): Pick => { + }): Pick => { const writeNativeAcpLog = (logInput: { readonly kind: "request" | "protocol"; readonly payload: unknown; @@ -47,17 +93,20 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" input.threadId, ); }).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to write native ACP event log.", { - cause, - provider: input.provider, - threadId: input.threadId, - }), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to write native ACP event log.", { + errorTag: causeErrorTag(cause), + reasonCount: cause.reasons.length, + provider: input.provider, + threadId: input.threadId, + }), ), ); return { - requestLogger: (event: AcpSessionRequestLogEvent) => + requestLogger: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) => writeNativeAcpLog({ kind: "request", payload: formatRequestLogPayload(event), @@ -70,9 +119,9 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => writeNativeAcpLog({ kind: "protocol", - payload: event, + payload: formatProtocolLogPayload(event), }), - } satisfies NonNullable, + } satisfies NonNullable, } : {}), }; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 4ba40cb27ac..f8c8f75a825 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -6,9 +7,9 @@ import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; -import * as Context from "effect/Context"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -77,50 +78,153 @@ export interface AcpSessionRuntimeStartResult { readonly modelConfigId: string | undefined; } -export interface AcpSessionRuntimeShape { - readonly handleRequestPermission: EffectAcpClient.AcpClientShape["handleRequestPermission"]; - readonly handleElicitation: EffectAcpClient.AcpClientShape["handleElicitation"]; - readonly handleReadTextFile: EffectAcpClient.AcpClientShape["handleReadTextFile"]; - readonly handleWriteTextFile: EffectAcpClient.AcpClientShape["handleWriteTextFile"]; - readonly handleCreateTerminal: EffectAcpClient.AcpClientShape["handleCreateTerminal"]; - readonly handleTerminalOutput: EffectAcpClient.AcpClientShape["handleTerminalOutput"]; - readonly handleTerminalWaitForExit: EffectAcpClient.AcpClientShape["handleTerminalWaitForExit"]; - readonly handleTerminalKill: EffectAcpClient.AcpClientShape["handleTerminalKill"]; - readonly handleTerminalRelease: EffectAcpClient.AcpClientShape["handleTerminalRelease"]; - readonly handleSessionUpdate: EffectAcpClient.AcpClientShape["handleSessionUpdate"]; - readonly handleElicitationComplete: EffectAcpClient.AcpClientShape["handleElicitationComplete"]; - readonly handleUnknownExtRequest: EffectAcpClient.AcpClientShape["handleUnknownExtRequest"]; - readonly handleUnknownExtNotification: EffectAcpClient.AcpClientShape["handleUnknownExtNotification"]; - readonly handleExtRequest: EffectAcpClient.AcpClientShape["handleExtRequest"]; - readonly handleExtNotification: EffectAcpClient.AcpClientShape["handleExtNotification"]; - readonly start: () => Effect.Effect; - readonly getEvents: () => Stream.Stream; - readonly getModeState: Effect.Effect; - readonly getConfigOptions: Effect.Effect>; - readonly prompt: ( - payload: Omit, - ) => Effect.Effect; - readonly cancel: Effect.Effect; - readonly setMode: ( - modeId: string, - ) => Effect.Effect; - readonly setConfigOption: ( - configId: string, - value: string | boolean, - ) => Effect.Effect; - readonly setModel: (model: string) => Effect.Effect; - readonly setSessionModel: ( - modelId: string, - ) => Effect.Effect; - readonly request: ( - method: string, - payload: unknown, - ) => Effect.Effect; - readonly notify: ( - method: string, - payload: unknown, - ) => Effect.Effect; -} +export class AcpSessionRuntime extends Context.Service< + AcpSessionRuntime, + { + /** + * Registers a handler for `session/request_permission`. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"]; + /** + * Registers a handler for `session/elicitation`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"]; + /** + * Registers a handler for `fs/read_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"]; + /** + * Registers a handler for `fs/write_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"]; + /** + * Registers a handler for `terminal/create`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"]; + /** + * Registers a handler for `terminal/output`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/output + */ + readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"]; + /** + * Registers a handler for `terminal/wait_for_exit`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit + */ + readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"]; + /** + * Registers a handler for `terminal/kill`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/kill + */ + readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"]; + /** + * Registers a handler for `terminal/release`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/release + */ + readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"]; + /** + * Registers a handler for `session/update`. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"]; + /** + * Registers a handler for `session/elicitation/complete`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"]; + /** + * Registers a fallback extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"]; + /** + * Registers a fallback extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"]; + /** + * Registers a typed extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"]; + /** + * Registers a typed extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; + /** + * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. + * Concurrent calls share the same in-flight startup and a failed startup may be retried. + */ + readonly start: () => Effect.Effect; + /** Stream of parsed ACP session events emitted after startup. */ + readonly getEvents: () => Stream.Stream; + /** Latest mode state observed from session setup and `session/update` notifications. */ + readonly getModeState: Effect.Effect; + /** Latest configuration options observed from session setup and configuration writes. */ + readonly getConfigOptions: Effect.Effect>; + /** + * Sends a prompt turn to the active session. + * @see https://agentclientprotocol.com/protocol/schema#session/prompt + */ + readonly prompt: ( + payload: Omit, + ) => Effect.Effect; + /** + * Sends a real ACP `session/cancel` notification for the active session. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel + */ + readonly cancel: Effect.Effect; + /** + * Selects the active mode through the negotiated `mode` configuration option. + * This is a no-op when the requested mode is already active. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setMode: ( + modeId: string, + ) => Effect.Effect; + /** + * Updates a session configuration option and the runtime configuration snapshot. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + /** + * Selects the base model through the negotiated model configuration option. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setModel: (model: string) => Effect.Effect; + /** + * Selects the active model through the unstable ACP `session/set_model` capability. + * @see https://agentclientprotocol.com/protocol/schema#session/set_model + */ + readonly setSessionModel: ( + modelId: string, + ) => Effect.Effect; + /** + * Sends a generic ACP extension request and records it through the request logger. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly request: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends a generic ACP extension notification. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly notify: ( + method: string, + payload: unknown, + ) => Effect.Effect; + } +>()("t3/provider/acp/AcpSessionRuntime") {} interface AcpStartedState extends AcpSessionRuntimeStartResult {} @@ -142,24 +246,10 @@ interface EnsureActiveAssistantSegmentResult { readonly startedEvent?: Extract; } -export class AcpSessionRuntime extends Context.Service()( - "t3/provider/acp/AcpSessionRuntime", -) { - static layer( - options: AcpSessionRuntimeOptions, - ): Layer.Layer< - AcpSessionRuntime, - EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner - > { - return Layer.effect(AcpSessionRuntime, makeAcpSessionRuntime(options)); - } -} - -const makeAcpSessionRuntime = ( +export const make = ( options: AcpSessionRuntimeOptions, ): Effect.Effect< - AcpSessionRuntimeShape, + AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > => @@ -584,9 +674,17 @@ const makeAcpSessionRuntime = ( request: (method, payload) => runLoggedRequest(method, payload, acp.raw.request(method, payload)), notify: acp.raw.notify, - } satisfies AcpSessionRuntimeShape; + } satisfies AcpSessionRuntime["Service"]; }); +export const layer = ( + options: AcpSessionRuntimeOptions, +): Layer.Layer< + AcpSessionRuntime, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner +> => Layer.effect(AcpSessionRuntime, make(options)); + function sessionConfigOptionsFromSetup( response: | { diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index 07b68d9815a..eebe5ddd92e 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -9,12 +9,12 @@ import * as Effect from "effect/Effect"; import { describe, expect } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { it.effect("initialize and authenticate against real agent acp", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); expect(started.initializeResult).toBeDefined(); }).pipe( @@ -42,7 +42,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", it.effect("session/new returns configOptions with a model selector", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); const result = started.sessionSetupResult; // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -97,7 +97,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", it.effect("session/set_config_option switches the model in-session", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); const newResult = started.sessionSetupResult; diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 49113013e2a..07f7c41f6c5 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -2,7 +2,7 @@ import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/cont import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import type * as EffectAcpErrors from "effect-acp/errors"; import { @@ -10,17 +10,12 @@ import { resolveCursorAcpBaseModelId, resolveCursorAcpConfigUpdates, } from "../Layers/CursorProvider.ts"; -import { - AcpSessionRuntime, - type AcpSessionRuntimeOptions, - type AcpSessionRuntimeShape, - type AcpSpawnInput, -} from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; type CursorAcpRuntimeCursorSettings = Pick; export interface CursorAcpRuntimeInput extends Omit< - AcpSessionRuntimeOptions, + AcpSessionRuntime.AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" > { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -38,7 +33,7 @@ export function buildCursorAcpSpawnInput( cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, -): AcpSpawnInput { +): AcpSessionRuntime.AcpSpawnInput { return { command: cursorSettings?.binaryPath || "agent", args: [ @@ -52,7 +47,11 @@ export function buildCursorAcpSpawnInput( export const makeCursorAcpRuntime = ( input: CursorAcpRuntimeInput, -): Effect.Effect => +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Scope.Scope +> => Effect.gen(function* () { const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ @@ -68,11 +67,13 @@ export const makeCursorAcpRuntime = ( ), ), ); - return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); }); interface CursorAcpModelSelectionRuntime { - readonly getConfigOptions: AcpSessionRuntimeShape["getConfigOptions"]; + readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"]; readonly setConfigOption: ( configId: string, value: string | boolean, diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 642548832fa..ee8af1e5266 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -2,17 +2,12 @@ import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { - AcpSessionRuntime, - type AcpSessionRuntimeOptions, - type AcpSessionRuntimeShape, - type AcpSpawnInput, -} from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; const GROK_API_KEY_ENV = "XAI_API_KEY"; const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; @@ -24,7 +19,7 @@ const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); type GrokAcpRuntimeGrokSettings = Pick; interface GrokAcpRuntimeInput extends Omit< - AcpSessionRuntimeOptions, + AcpSessionRuntime.AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" > { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -36,7 +31,7 @@ export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, -): AcpSpawnInput { +): AcpSessionRuntime.AcpSpawnInput { return { command: grokSettings?.binaryPath || "grok", args: ["agent", "stdio"], @@ -56,7 +51,11 @@ function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): st export const makeGrokAcpRuntime = ( input: GrokAcpRuntimeInput, -): Effect.Effect => +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Scope.Scope +> => Effect.gen(function* () { const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ @@ -69,7 +68,9 @@ export const makeGrokAcpRuntime = ( ), ), ); - return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); }); export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { @@ -88,7 +89,7 @@ export function currentGrokModelIdFromSessionSetup( } export function applyGrokAcpModelSelection(input: { - readonly runtime: Pick; + readonly runtime: Pick; readonly currentModelId: string | undefined; readonly requestedModelId: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 88547fb3afa..bbf301fa407 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -21,7 +21,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Settings, >(input: { readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"]; - readonly getSettings: Effect.Effect; + readonly getSettings: Effect.Effect; readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 365884da85d..a83c134d5bd 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -1,4 +1,4 @@ -import { pathToFileURL } from "node:url"; +import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; import { @@ -206,7 +206,7 @@ export function toOpenCodeFileParts(input: { type: "file", mime: attachment.mimeType, filename: attachment.name, - url: pathToFileURL(attachmentPath).href, + url: NodeURL.pathToFileURL(attachmentPath).href, }); } diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index c4ad2fa7509..8937844f613 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,16 +1,17 @@ // @effect-diagnostics nodeBuiltinImport:off import { expect, it } from "@effect/vitest"; -import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; -import path from "node:path"; -import { ProviderDriverKind } from "@t3tools/contracts"; +import * as NodePath from "node:path"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import { HttpClient } from "effect/unstable/http"; import { createProviderVersionAdvisory, + enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, @@ -24,7 +25,7 @@ const driver = (value: string) => ProviderDriverKind.make(value); const makeTempDir = (name: string) => Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.map((id) => path.join(NodeOS.tmpdir(), `${name}-${id}`)), + Effect.map((id) => NodePath.join(NodeOS.tmpdir(), `${name}-${id}`)), ); const isNativeTestCommandPath = (expectedPathSegment: string) => @@ -67,6 +68,19 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( updateLockKey: "static-tool", }), ); +const installedPackageToolProvider: ServerProvider = { + instanceId: ProviderInstanceId.make("packageTool"), + driver: driver("packageTool"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("reads cached versions through the injectable cache reference", () => @@ -95,6 +109,32 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ), ); + it.effect("does not fetch latest provider versions when update checks are disabled", () => + enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + packageToolUpdate.resolve(), + { + enableProviderUpdateChecks: false, + }, + ).pipe( + Effect.provideService(ProviderVersionCache, new Map()), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die("disabled provider update checks should not make an HTTP request"), + ), + ), + Effect.map((provider) => { + expect(provider.versionAdvisory).toMatchObject({ + status: "unknown", + currentVersion: "1.0.0", + latestVersion: null, + checkedAt: "2026-04-10T00:00:00.000Z", + }); + }), + ), + ); + it("marks providers with unknown current versions as unknown", () => { expect( createProviderVersionAdvisory({ @@ -163,11 +203,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-vite-plus-capabilities"); - const vitePlusBinDir = path.join(tempDir, ".vite-plus", "bin"); - mkdirSync(vitePlusBinDir, { recursive: true }); - const packageToolPath = path.join(vitePlusBinDir, "package-tool"); - writeFileSync(packageToolPath, "#!/bin/sh\n"); - chmodSync(packageToolPath, 0o755); + const vitePlusBinDir = NodePath.join(tempDir, ".vite-plus", "bin"); + NodeFS.mkdirSync(vitePlusBinDir, { recursive: true }); + const packageToolPath = NodePath.join(vitePlusBinDir, "package-tool"); + NodeFS.writeFileSync(packageToolPath, "#!/bin/sh\n"); + NodeFS.chmodSync(packageToolPath, 0o755); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( packageToolUpdate, @@ -200,9 +240,9 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-bun-capabilities"); - const bunBinDir = path.join(tempDir, ".bun", "bin"); - mkdirSync(bunBinDir, { recursive: true }); - writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); + const bunBinDir = NodePath.join(tempDir, ".bun", "bin"); + NodeFS.mkdirSync(bunBinDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(bunBinDir, "native-package-tool.exe"), "MZ"); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( nativePackageToolUpdate, @@ -236,11 +276,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-pnpm-capabilities"); - const pnpmHomeDir = path.join(tempDir, ".local", "share", "pnpm"); - mkdirSync(pnpmHomeDir, { recursive: true }); - const scopedPackageToolPath = path.join(pnpmHomeDir, "scoped-package-tool"); - writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); - chmodSync(scopedPackageToolPath, 0o755); + const pnpmHomeDir = NodePath.join(tempDir, ".local", "share", "pnpm"); + NodeFS.mkdirSync(pnpmHomeDir, { recursive: true }); + const scopedPackageToolPath = NodePath.join(pnpmHomeDir, "scoped-package-tool"); + NodeFS.writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); + NodeFS.chmodSync(scopedPackageToolPath, 0o755); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( scopedPackageToolUpdate, @@ -296,11 +336,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-native-package-tool-native-capabilities"); - const nativeBinDir = path.join(tempDir, ".local", "bin"); - mkdirSync(nativeBinDir, { recursive: true }); - const nativePackageToolPath = path.join(nativeBinDir, "native-package-tool"); - writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); - chmodSync(nativePackageToolPath, 0o755); + const nativeBinDir = NodePath.join(tempDir, ".local", "bin"); + NodeFS.mkdirSync(nativeBinDir, { recursive: true }); + const nativePackageToolPath = NodePath.join(nativeBinDir, "native-package-tool"); + NodeFS.writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); + NodeFS.chmodSync(nativePackageToolPath, 0o755); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( nativePackageToolUpdate, @@ -333,11 +373,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-scoped-package-tool-native-capabilities"); - const nativeBinDir = path.join(tempDir, ".scoped-package-tool", "bin"); - mkdirSync(nativeBinDir, { recursive: true }); - const scopedPackageToolPath = path.join(nativeBinDir, "scoped-package-tool"); - writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); - chmodSync(scopedPackageToolPath, 0o755); + const nativeBinDir = NodePath.join(tempDir, ".scoped-package-tool", "bin"); + NodeFS.mkdirSync(nativeBinDir, { recursive: true }); + const scopedPackageToolPath = NodePath.join(nativeBinDir, "scoped-package-tool"); + NodeFS.writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); + NodeFS.chmodSync(scopedPackageToolPath, 0o755); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( scopedPackageToolUpdate, @@ -414,8 +454,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-npm-capabilities"); - const binDir = path.join(tempDir, "bin"); - const packageBinDir = path.join( + const binDir = NodePath.join(tempDir, "bin"); + const packageBinDir = NodePath.join( tempDir, "lib", "node_modules", @@ -423,13 +463,13 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "package-tool", "bin", ); - mkdirSync(binDir, { recursive: true }); - mkdirSync(packageBinDir, { recursive: true }); - const packageBinPath = path.join(packageBinDir, "package-tool.js"); - const symlinkPath = path.join(binDir, "package-tool"); - writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); - chmodSync(packageBinPath, 0o755); - symlinkSync(packageBinPath, symlinkPath); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = NodePath.join(packageBinDir, "package-tool.js"); + const symlinkPath = NodePath.join(binDir, "package-tool"); + NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + NodeFS.chmodSync(packageBinPath, 0o755); + NodeFS.symlinkSync(packageBinPath, symlinkPath); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, @@ -457,8 +497,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities"); - const binDir = path.join(tempDir, "bin"); - const packageBinDir = path.join( + const binDir = NodePath.join(tempDir, "bin"); + const packageBinDir = NodePath.join( tempDir, ".local", "share", @@ -470,13 +510,13 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "package-tool", "bin", ); - mkdirSync(binDir, { recursive: true }); - mkdirSync(packageBinDir, { recursive: true }); - const packageBinPath = path.join(packageBinDir, "package-tool.js"); - const symlinkPath = path.join(binDir, "package-tool"); - writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); - chmodSync(packageBinPath, 0o755); - symlinkSync(packageBinPath, symlinkPath); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = NodePath.join(packageBinDir, "package-tool.js"); + const symlinkPath = NodePath.join(binDir, "package-tool"); + NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + NodeFS.chmodSync(packageBinPath, 0o755); + NodeFS.symlinkSync(packageBinPath, symlinkPath); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index d1c4a7d6a71..8645f9f943c 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -468,10 +468,21 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( "enrichProviderSnapshotWithVersionAdvisory", -)(function* (snapshot: ServerProvider, maintenanceCapabilities?: ProviderMaintenanceCapabilities) { +)(function* ( + snapshot: ServerProvider, + maintenanceCapabilities?: ProviderMaintenanceCapabilities, + options?: { + readonly enableProviderUpdateChecks: boolean | undefined; + }, +) { const capabilities = maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver); - if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { + const shouldResolveLatestVersion = + options?.enableProviderUpdateChecks !== false && + snapshot.enabled && + snapshot.installed && + Boolean(snapshot.version); + if (!shouldResolveLatestVersion) { return { ...snapshot, versionAdvisory: createProviderVersionAdvisory({ diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index fdc8b4c4a71..abe138fdfb9 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,8 +1,19 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { providerModelsFromSettings } from "./providerSnapshot.ts"; +import { + isCommandMissingCause, + providerModelsFromSettings, + spawnAndCollect, +} from "./providerSnapshot.ts"; const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ @@ -42,3 +53,66 @@ describe("providerModelsFromSettings", () => { ]); }); }); + +describe("ProviderCommandNotFoundError", () => { + it("classifies normalized platform failures without parsing messages", () => { + expect( + isCommandMissingCause( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "arbitrary host detail", + }), + ), + ).toBe(true); + expect(isCommandMissingCause(new Error("spawn provider ENOENT"))).toBe(false); + }); + + it.effect("retains safe failed-command diagnostics without process output", () => { + const stderr = "'codex' is not recognized: secret-token-value"; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9009)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + return Effect.gen(function* () { + const error = yield* spawnAndCollect( + "C:\\tools\\codex.cmd", + ChildProcess.make("codex", ["--version"]), + ).pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.flip, + ); + + if (error._tag !== "ProviderCommandNotFoundError") { + throw new Error(`Unexpected error: ${error._tag}`); + } + + expect(error.binaryPath).toBe("C:\\tools\\codex.cmd"); + expect(error.exitCode).toBe(9009); + expect(error.stdoutLength).toBe(0); + expect(error.stderrLength).toBe(stderr.length); + expect(error.message).toBe( + "Provider command C:\\tools\\codex.cmd was not found (exit code 9009).", + ); + expect(isCommandMissingCause(error)).toBe(true); + expect(error).not.toHaveProperty("stdout"); + expect(error).not.toHaveProperty("stderr"); + expect(error.message).not.toContain("secret-token-value"); + }); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 2ecb3220773..dfe31ffdc44 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -9,7 +9,8 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Data from "effect/Data"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; @@ -27,11 +28,21 @@ export interface CommandResult { readonly code: number; } -export class ProviderCommandExecutionError extends Data.TaggedError( - "ProviderCommandExecutionError", -)<{ - readonly message: string; -}> {} +export class ProviderCommandNotFoundError extends Schema.TaggedErrorClass()( + "ProviderCommandNotFoundError", + { + binaryPath: Schema.String, + exitCode: Schema.Number, + stdoutLength: Schema.Number, + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `Provider command ${this.binaryPath} was not found (exit code ${this.exitCode}).`; + } +} + +const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError); export interface ProviderProbeResult { readonly installed: boolean; @@ -56,9 +67,9 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -export function isCommandMissingCause(error: { readonly message: string }): boolean { - const lower = error.message.toLowerCase(); - return lower.includes("enoent") || lower.includes("notfound"); +export function isCommandMissingCause(error: unknown): boolean { + if (isProviderCommandNotFoundError(error)) return true; + return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; } export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => @@ -76,7 +87,12 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman const result: CommandResult = { stdout, stderr, code: exitCode }; if (yield* isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); + return yield* new ProviderCommandNotFoundError({ + binaryPath, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); } return result; }).pipe(Effect.scoped); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 64cb9ccd417..07f67cd7de8 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -9,6 +9,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Logger from "effect/Logger"; import { hydrateCachedProvider, @@ -42,6 +43,39 @@ const makeProvider = ( }); it.layer(NodeServices.layer)("providerStatusCache", (it) => { + it.effect("logs structural diagnostics without retaining invalid cache contents", () => { + const messages: Array = []; + const logger = Logger.make((options) => { + if (Array.isArray(options.message)) { + messages.push(...options.message); + } else { + messages.push(options.message); + } + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-invalid-" }); + const cachePath = `${tempDir}/provider.json`; + const secretCacheValue = "secret-cache-value"; + yield* fs.writeFileString(cachePath, `{ "token": "${secretCacheValue}" }`); + + const result = yield* readProviderStatusCache(cachePath); + + assert.strictEqual(result, undefined); + const failure = messages.find( + (message): message is Record => + typeof message === "object" && message !== null && "path" in message, + ); + assert.exists(failure); + assert.strictEqual(failure.path, cachePath); + assert.strictEqual(typeof failure.errorTag, "string"); + assert.ok(!("cause" in failure)); + assert.ok(!("issues" in failure)); + assert.ok(!Object.values(failure).map(String).join("\n").includes(secretCacheValue)); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + it.effect("writes and reads provider status snapshots", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 0b9b365f360..2fe0424b4f5 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -4,7 +4,7 @@ import { type ServerProvider, ServerProvider as ServerProviderSchema, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -134,7 +134,7 @@ export const readProviderStatusCache = (filePath: string) => onFailure: (cause) => Effect.logWarning("failed to parse provider status cache, ignoring", { path: filePath, - issues: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }).pipe(Effect.as(undefined)), onSuccess: Effect.succeed, }), diff --git a/apps/server/src/provider/providerUpdateSettings.ts b/apps/server/src/provider/providerUpdateSettings.ts new file mode 100644 index 00000000000..308d84a1446 --- /dev/null +++ b/apps/server/src/provider/providerUpdateSettings.ts @@ -0,0 +1,43 @@ +import type { ServerSettings, ServerSettingsError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Stream from "effect/Stream"; + +import type * as ServerSettingsModule from "../serverSettings.ts"; + +export interface ProviderSnapshotSettings { + readonly provider: Settings; + readonly enableProviderUpdateChecks: boolean; +} + +export function makeProviderSnapshotSettings( + provider: Settings, + settings: ServerSettings, +): ProviderSnapshotSettings { + return { + provider, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }; +} + +export function haveProviderSnapshotSettingsChanged( + previous: ProviderSnapshotSettings, + next: ProviderSnapshotSettings, +): boolean { + return !Equal.equals(previous, next); +} + +export function makeProviderSnapshotSettingsSource( + provider: Settings, + serverSettings: ServerSettingsModule.ServerSettingsService["Service"], +): { + readonly getSettings: Effect.Effect, ServerSettingsError>; + readonly streamSettings: Stream.Stream>; +} { + const mapSettings = (settings: ServerSettings) => + makeProviderSnapshotSettings(provider, settings); + return { + getSettings: serverSettings.getSettings.pipe(Effect.map(mapSettings)), + streamSettings: serverSettings.streamChanges.pipe(Stream.map(mapSettings)), + }; +} diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9458dcc45de..c707aed3940 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -29,7 +29,7 @@ import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -64,17 +64,18 @@ function makeMemorySecretStore() { const values = new Map(); const store = { get: ((name) => - Effect.sync( - () => values.get(name) ?? null, - )) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + Effect.sync(() => { + const value = values.get(name); + return value === undefined ? Option.none() : Option.some(Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["get"], set: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["set"], create: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["create"], getOrCreateRandom: ((name, bytes) => Effect.sync(() => { const existing = values.get(name); @@ -84,12 +85,12 @@ function makeMemorySecretStore() { const generated = new Uint8Array(bytes); values.set(name, generated); return generated; - })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["getOrCreateRandom"], remove: ((name) => Effect.sync(() => { values.delete(name); - })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], - } satisfies ServerSecretStore.ServerSecretStoreShape; + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["remove"], + } satisfies ServerSecretStore.ServerSecretStore["Service"]; return { store, setString: (name: string, value: string) => store.set(name, encodeSecret(value)), @@ -97,6 +98,27 @@ function makeMemorySecretStore() { } describe.sequential("signRelayAgentActivityPublishProof", () => { + it("distinguishes pending link credentials from disabled publication", () => { + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: false, + publishEnabled: false, + }), + ).toBe("waiting-for-link"); + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: true, + publishEnabled: false, + }), + ).toBe("disabled"); + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: true, + publishEnabled: true, + }), + ).toBe("enabled"); + }); + it("derives the thread id from the aggregate id for thread events without payload thread ids", () => { const threadId = "thread-aggregate-1" as ThreadId; const now = "2026-05-25T00:00:00.000Z"; @@ -475,7 +497,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const layer = Layer.mergeAll( Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), - Layer.succeed(ServerEnvironment, { + Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.succeed(descriptor), }), @@ -608,7 +630,11 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } satisfies ExecutionEnvironmentDescriptor; globalThis.fetch = ((input: Parameters[0]) => { - const url = new URL(input instanceof Request ? input.url : input.toString()); + const url = new URL( + typeof input === "string" || input instanceof URL + ? input + : (input as unknown as { readonly url: string }).url, + ); runFork(Deferred.succeed(fetchSeen, url)); return Promise.resolve(Response.json({ ok: true, deliveries: [] })); }) as unknown as typeof fetch; @@ -620,7 +646,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const layer = Layer.mergeAll( Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), - Layer.succeed(ServerEnvironment, { + Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.succeed(descriptor), }), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index d02c83d563e..4e036e3ea0e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -1,8 +1,3 @@ -import { - RelayApi, - type RelayAgentActivityPublishProofPayload, - type RelayAgentActivityState, -} from "@t3tools/contracts/relay"; import type { EnvironmentId, OrchestrationEvent, @@ -10,13 +5,18 @@ import type { OrchestrationThreadShell, ThreadId, } from "@t3tools/contracts"; +import { + RelayApi, + type RelayAgentActivityPublishProofPayload, + type RelayAgentActivityState, +} from "@t3tools/contracts/relay"; import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { + normalizeRelayIssuer, RELAY_ACTIVITY_PUBLISH_TYP, signRelayJwt, - normalizeRelayIssuer, } from "@t3tools/shared/relayJwt"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -28,31 +28,29 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { FetchHttpClient } from "effect/unstable/http"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; import { PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, RELAY_URL_SECRET, } from "../cloud/config.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; -import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; - -export interface AgentAwarenessRelayShape { - readonly publishThread: (threadId: ThreadId) => Effect.Effect; - readonly start: () => Effect.Effect; -} +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, - AgentAwarenessRelayShape + { + readonly publishThread: (threadId: ThreadId) => Effect.Effect; + readonly start: () => Effect.Effect; + } >()("t3/relay/AgentAwarenessRelay") {} export function eventThreadId(event: OrchestrationEvent): ThreadId | null { @@ -100,6 +98,16 @@ export function isAgentActivityPublishingEnabled(value: string | null): boolean return value === "true"; } +export function resolveAgentActivityPublishingStartupState(input: { + readonly relayConfigured: boolean; + readonly publishEnabled: boolean; +}): "waiting-for-link" | "disabled" | "enabled" { + if (!input.relayConfigured) { + return "waiting-for-link"; + } + return input.publishEnabled ? "enabled" : "disabled"; +} + const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; @@ -255,18 +263,24 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { .map((thread) => thread.id); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; - const serverEnvironment = yield* ServerEnvironment; - const snapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const crypto = yield* Crypto.Crypto; const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); const activeSnapshotPublishedRef = yield* Ref.make(false); const publishedStateByThreadRef = yield* Ref.make(new Map()); const readSecretString = (name: string) => - secrets.get(name).pipe(Effect.map((bytes) => (bytes ? new TextDecoder().decode(bytes) : null))); + secrets + .get(name) + .pipe( + Effect.map((bytes) => + Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, + ), + ); const readRelayConfig = Effect.gen(function* () { const [url, issuer, environmentCredential] = yield* Effect.all([ @@ -304,7 +318,7 @@ const make = Effect.gen(function* () { } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logDebug("agent activity publish skipped; T3 Connect config missing", { + yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", { threadId, }); return; @@ -401,7 +415,7 @@ const make = Effect.gen(function* () { }); }); - const publishThread: AgentAwarenessRelayShape["publishThread"] = (threadId) => + const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => publishThreadUnsafe(threadId).pipe( Effect.catchCause((cause) => { return Effect.logWarning("agent activity publish failed", { @@ -423,7 +437,7 @@ const make = Effect.gen(function* () { } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logDebug("agent activity snapshot skipped; T3 Connect config missing"); + yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); return false; } const environmentId = yield* serverEnvironment.getEnvironmentId; @@ -444,31 +458,55 @@ const make = Effect.gen(function* () { return true; }); - const publishActiveThreadsOnceWhenConfigured = Effect.gen(function* () { - while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); - if (published) { - yield* Ref.set(activeSnapshotPublishedRef, true); - return; + const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => + Effect.gen(function* () { + while (!(yield* Ref.get(activeSnapshotPublishedRef))) { + const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); + if (published) { + yield* Ref.set(activeSnapshotPublishedRef, true); + if (logEnabledWhenReady) { + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { + relayUrl: relayConfig?.url, + }); + } + return; + } + yield* Effect.sleep("5 seconds"); } - yield* Effect.sleep("5 seconds"); - } - }); + }); const worker = yield* makeDrainableWorker(publishThread); - const start: AgentAwarenessRelayShape["start"] = Effect.fn("AgentAwarenessRelay.start")( + const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( function* () { - const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); - if (!relayConfig) { - yield* Effect.logInfo("agent activity publishing standby; T3 Connect config missing"); - } else { - yield* Effect.logInfo("agent activity publishing enabled", { - relayUrl: relayConfig.url, - }); + const [relayConfig, publishEnabled] = yield* Effect.all([ + readRelayConfig.pipe(Effect.orElseSucceed(() => null)), + readPublishAgentActivityEnabled.pipe(Effect.orElseSucceed(() => false)), + ]); + const startupState = resolveAgentActivityPublishingStartupState({ + relayConfigured: relayConfig !== null, + publishEnabled, + }); + switch (startupState) { + case "waiting-for-link": + yield* Effect.logInfo( + "agent activity publishing standby; waiting for T3 Connect link reconciliation", + ); + break; + case "disabled": + yield* Effect.logInfo("agent activity publishing disabled by T3 Connect configuration"); + break; + case "enabled": + yield* Effect.logInfo("agent activity publishing enabled", { + relayUrl: relayConfig?.url, + }); + break; } yield* Effect.forkScoped( - Effect.sleep("1 second").pipe(Effect.andThen(publishActiveThreadsOnceWhenConfigured)), + Effect.sleep("1 second").pipe( + Effect.andThen(publishActiveThreadsOnceWhenConfigured(startupState !== "enabled")), + ), ); yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { @@ -496,10 +534,10 @@ const make = Effect.gen(function* () { }, ); - return { + return AgentAwarenessRelay.of({ publishThread, start, - } satisfies AgentAwarenessRelayShape; + }); }); export const layer = Layer.effect(AgentAwarenessRelay, make); diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index eb8758b1282..839eb73b2bb 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -73,4 +74,27 @@ describe("ReviewService", () => { assert.deepStrictEqual(detectCalls, [{ cwd: workspaceRoot }]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("preserves unexpected path-resolution failures", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const invalidCwd = `${workspaceRoot}\0invalid`; + const detectCalls: Array<{ readonly cwd: string }> = []; + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: invalidCwd }).pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); + + assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); + if (error._tag !== "VcsRepositoryDetectionError") return; + assert.strictEqual(error.operation, "ReviewService.assertWorkspaceBoundCwd.canonicalizePath"); + assert.strictEqual(error.cwd, invalidCwd); + assert.match(error.detail, /Failed to resolve a path/); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.deepStrictEqual(detectCalls, []); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 63f1d133213..db1dc5bc8d2 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -13,29 +13,44 @@ import { type ReviewDiffPreviewResult, } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -export interface ReviewServiceShape { - readonly getDiffPreview: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; -} - -export class ReviewService extends Context.Service()( - "t3/review/ReviewService", -) {} - -export const make = Effect.fn("makeReviewService")(function* () { - const config = yield* ServerConfig; +export class ReviewService extends Context.Service< + ReviewService, + { + readonly getDiffPreview: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + } +>()("t3/review/ReviewService") {} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; - const canonicalizePath = (value: string) => - fileSystem.realPath(path.resolve(value)).pipe(Effect.orElseSucceed(() => path.resolve(value))); + const canonicalizePath = (value: string) => { + const resolvedPath = path.resolve(value); + return fileSystem.realPath(resolvedPath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(resolvedPath) + : Effect.fail( + new VcsRepositoryDetectionError({ + operation: "ReviewService.assertWorkspaceBoundCwd.canonicalizePath", + cwd: resolvedPath, + detail: "Failed to resolve a path while validating the review workspace.", + cause, + }), + ), + }), + ); + }; const isWithinRoot = (candidate: string, root: string) => { const relative = path.relative(root, candidate); @@ -62,7 +77,7 @@ export const make = Effect.fn("makeReviewService")(function* () { }); }); - const getDiffPreview: ReviewServiceShape["getDiffPreview"] = Effect.fn( + const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn( "ReviewService.getDiffPreview", )(function* (input) { yield* assertWorkspaceBoundCwd(input.cwd); @@ -96,4 +111,4 @@ export const make = Effect.fn("makeReviewService")(function* () { }); }); -export const layer = Layer.effect(ReviewService, make()); +export const layer = Layer.effect(ReviewService, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 20f32d0c1b4..547df437684 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { AuthAccessTokenType, @@ -14,7 +15,7 @@ import { GitCommandError, KeybindingRule, MessageId, - ExternalLauncherError, + ExternalLauncherCommandNotFoundError, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -70,59 +71,33 @@ import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -import type { ServerConfigShape } from "./config.ts"; -import { deriveServerPaths, ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { - CheckpointDiffQuery, - type CheckpointDiffQueryShape, -} from "./checkpointing/Services/CheckpointDiffQuery.ts"; -import { GitManager, type GitManagerShape } from "./git/GitManager.ts"; -import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; +import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as GitManager from "./git/GitManager.ts"; +import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; -import { - OrchestrationEngineService, - type OrchestrationEngineShape, -} from "./orchestration/Services/OrchestrationEngine.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; -import { - ProjectionSnapshotQuery, - type ProjectionSnapshotQueryShape, -} from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; -import { - ProviderRegistry, - type ProviderRegistryShape, -} from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; -import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; -import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; -import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; -import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; -import { - BrowserTraceCollector, - type BrowserTraceCollectorShape, -} from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; -import { - ProjectSetupScriptRunner, - ProjectSetupScriptRunnerError, - type ProjectSetupScriptRunnerShape, -} from "./project/Services/ProjectSetupScriptRunner.ts"; -import { - RepositoryIdentityResolver, - type RepositoryIdentityResolverShape, -} from "./project/Services/RepositoryIdentityResolver.ts"; -import { - ServerEnvironment, - type ServerEnvironmentShape, -} from "./environment/Services/ServerEnvironment.ts"; +import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; +import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -133,10 +108,7 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { - CloudManagedEndpointRuntime, - type CloudManagedEndpointRuntimeShape, -} from "./cloud/ManagedEndpointRuntime.ts"; +import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; @@ -344,32 +316,40 @@ const makeBrowserOtlpPayload = (spanName: string) => }); const buildAppUnderTest = (options?: { - config?: Partial; + config?: Partial; layers?: { - keybindings?: Partial; - providerRegistry?: Partial; - serverSettings?: Partial; - externalLauncher?: Partial; - vcsDriver?: Partial; - vcsDriverRegistry?: Partial; - gitVcsDriver?: Partial; - gitManager?: Partial; - sourceControlRepositoryService?: Partial; - reviewService?: Partial; - vcsStatusBroadcaster?: Partial; - projectSetupScriptRunner?: Partial; - terminalManager?: Partial; - orchestrationEngine?: Partial; - projectionSnapshotQuery?: Partial; - checkpointDiffQuery?: Partial; - browserTraceCollector?: Partial; - serverLifecycleEvents?: Partial; - serverRuntimeStartup?: Partial; - serverEnvironment?: Partial; - repositoryIdentityResolver?: Partial; - cloudManagedEndpointRuntime?: Partial; - relayClient?: Partial; - cloudCliTokenManager?: Partial; + keybindings?: Partial; + providerRegistry?: Partial; + serverSettings?: Partial; + externalLauncher?: Partial; + vcsDriver?: Partial; + vcsDriverRegistry?: Partial; + gitVcsDriver?: Partial; + gitManager?: Partial; + sourceControlRepositoryService?: Partial< + SourceControlRepositoryService.SourceControlRepositoryService["Service"] + >; + reviewService?: Partial; + vcsStatusBroadcaster?: Partial; + projectSetupScriptRunner?: Partial< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] + >; + terminalManager?: Partial; + orchestrationEngine?: Partial; + projectionSnapshotQuery?: Partial; + checkpointDiffQuery?: Partial; + browserTraceCollector?: Partial; + serverLifecycleEvents?: Partial; + serverRuntimeStartup?: Partial; + serverEnvironment?: Partial; + repositoryIdentityResolver?: Partial< + RepositoryIdentityResolver.RepositoryIdentityResolver["Service"] + >; + cloudManagedEndpointRuntime?: Partial< + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"] + >; + relayClient?: Partial; + cloudCliTokenManager?: Partial; }; }) => Effect.gen(function* () { @@ -377,8 +357,8 @@ const buildAppUnderTest = (options?: { const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); const baseDir = options?.config?.baseDir ?? tempBaseDir; const devUrl = options?.config?.devUrl; - const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - const config: ServerConfigShape = { + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl); + const config: ServerConfig.ServerConfig["Service"] = { logLevel: "Info", traceMinLevel: "Info", traceTimingEnabled: true, @@ -407,8 +387,8 @@ const buildAppUnderTest = (options?: { tailscaleServePort: 443, ...options?.config, }; - const layerConfig = Layer.succeed(ServerConfig, config); - const defaultVcsDriver: VcsDriver.VcsDriverShape = { + const layerConfig = ServerConfig.layer(config); + const defaultVcsDriver: VcsDriver.VcsDriver["Service"] = { capabilities: { kind: "git", supportsWorktrees: true, @@ -506,21 +486,21 @@ const buildAppUnderTest = (options?: { const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ ...options?.layers?.gitVcsDriver, }); - const gitManagerLayer = Layer.mock(GitManager)({ + const gitManagerLayer = Layer.mock(GitManager.GitManager)({ ...options?.layers?.gitManager, }); const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( - Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspacePaths.layer), Layer.provideMerge(vcsDriverRegistryLayer), ); const workspaceAndProjectServicesLayer = Layer.mergeAll( - WorkspacePathsLive, + WorkspacePaths.layer, workspaceEntriesLayer, - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), + WorkspaceFileSystem.layer.pipe( + Layer.provide(WorkspacePaths.layer), Layer.provide(workspaceEntriesLayer), ), - ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), + ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), @@ -549,7 +529,7 @@ const buildAppUnderTest = (options?: { disableLogger: true, }).pipe( Layer.provide( - Layer.mock(Keybindings)({ + Layer.mock(Keybindings.Keybindings)({ loadConfigState: Effect.succeed({ keybindings: [], issues: [], @@ -559,7 +539,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry)({ + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), refreshInstance: () => Effect.succeed([]), @@ -573,7 +553,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ServerSettingsService)({ + Layer.mock(ServerSettings.ServerSettingsService)({ start: Effect.void, ready: Effect.void, getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), @@ -662,13 +642,13 @@ const buildAppUnderTest = (options?: { ), Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( - Layer.mock(ProjectSetupScriptRunner)({ + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), ...options?.layers?.projectSetupScriptRunner, }), ), Layer.provide( - Layer.mock(TerminalManager)({ + Layer.mock(TerminalManager.TerminalManager)({ ...options?.layers?.terminalManager, }), ), @@ -696,7 +676,7 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(OrchestrationEngineService)({ + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 0 }), streamDomainEvents: Stream.empty, @@ -704,7 +684,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProjectionSnapshotQuery)({ + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getShellSnapshot: () => @@ -733,7 +713,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(CheckpointDiffQuery)({ + Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({ getTurnDiff: () => Effect.succeed({ threadId: defaultThreadId, @@ -755,13 +735,13 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide( - Layer.mock(BrowserTraceCollector)({ + Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, ...options?.layers?.browserTraceCollector, }), ), Layer.provide( - Layer.mock(ServerLifecycleEvents)({ + Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), snapshot: Effect.succeed({ sequence: 0, events: [] }), stream: Stream.empty, @@ -769,7 +749,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ServerRuntimeStartup)({ + Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({ awaitCommandReady: Effect.void, markHttpListening: Effect.void, enqueueCommand: (effect) => effect, @@ -777,22 +757,22 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ServerEnvironment)({ + Layer.mock(ServerEnvironment.ServerEnvironment)({ getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), getDescriptor: Effect.succeed(testEnvironmentDescriptor), ...options?.layers?.serverEnvironment, }), ), Layer.provide( - Layer.mock(RepositoryIdentityResolver)({ + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ resolve: () => Effect.succeed(null), ...options?.layers?.repositoryIdentityResolver, }), ), Layer.provide( Layer.succeed( - CloudManagedEndpointRuntime, - CloudManagedEndpointRuntime.of({ + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ applyConfig: () => Effect.succeed({ status: "disabled" }), ...options?.layers?.cloudManagedEndpointRuntime, }), @@ -4455,27 +4435,147 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("routes websocket rpc projects.searchEntries errors", () => + it.effect("preserves structured workspace rpc failures", () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-errors-", + }); + const outsideDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-errors-outside-", + }); + const outsideFile = path.join(outsideDir, "outside.txt"); + yield* fs.writeFileString(outsideFile, "outside\n"); + yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt")); + const resolvedOutsideFile = yield* fs.realPath(outsideFile); + yield* buildAppUnderTest(); + const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); + const missingBrowseParent = path.join(workspaceDir, "missing-browse"); + const sensitiveQuery = "authorization: Bearer secret-token"; const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( + const results = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.projectsSearchEntries]({ - cwd: "/definitely/not/a/real/workspace/path", - query: "needle", - limit: 10, + Effect.all({ + search: client[WS_METHODS.projectsSearchEntries]({ + cwd: invalidWorkspace, + query: sensitiveQuery, + limit: 10, + }).pipe(Effect.result), + list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( + Effect.result, + ), + read: client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "linked-outside.txt", + }).pipe(Effect.result), + browse: client[WS_METHODS.filesystemBrowse]({ + cwd: workspaceDir, + partialPath: "./missing-browse/child", + }).pipe(Effect.result), }), - ).pipe(Effect.result), + ), ); - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "ProjectSearchEntriesError"); - assertInclude( - result.failure.message, - "Workspace root does not exist: /definitely/not/a/real/workspace/path", + if ( + results.search._tag !== "Failure" || + results.search.failure._tag !== "ProjectSearchEntriesError" + ) { + assert.fail("Expected a ProjectSearchEntriesError"); + } + const searchError = results.search.failure; + assert.equal( + searchError.message, + `Failed to search workspace entries in '${invalidWorkspace}'.`, ); + assert.equal(searchError.cwd, invalidWorkspace); + assert.equal(searchError.queryLength, sensitiveQuery.length); + assert.notProperty(searchError, "query"); + assert.notInclude(searchError.message, "Bearer"); + assert.notInclude(searchError.message, "secret-token"); + assert.equal(searchError.limit, 10); + assert.equal(searchError.failure, "workspace_root_not_found"); + assert.equal(searchError.normalizedCwd, invalidWorkspace); + assert.isDefined(searchError.cause); + + if ( + results.list._tag !== "Failure" || + results.list.failure._tag !== "ProjectListEntriesError" + ) { + assert.fail("Expected a ProjectListEntriesError"); + } + const listError = results.list.failure; + assert.equal(listError.message, `Failed to list workspace entries in '${invalidWorkspace}'.`); + assert.equal(listError.cwd, invalidWorkspace); + assert.equal(listError.failure, "workspace_root_not_found"); + assert.equal(listError.normalizedCwd, invalidWorkspace); + assert.isDefined(listError.cause); + + if (results.read._tag !== "Failure" || results.read.failure._tag !== "ProjectReadFileError") { + assert.fail("Expected a ProjectReadFileError"); + } + const readError = results.read.failure; + assert.equal( + readError.message, + `Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`, + ); + assert.equal(readError.cwd, workspaceDir); + assert.equal(readError.relativePath, "linked-outside.txt"); + assert.equal(readError.failure, "resolved_path_outside_root"); + assert.equal(readError.resolvedPath, resolvedOutsideFile); + assert.isDefined(readError.cause); + + if ( + results.browse._tag !== "Failure" || + results.browse.failure._tag !== "FilesystemBrowseError" + ) { + assert.fail("Expected a FilesystemBrowseError"); + } + const browseError = results.browse.failure; + assert.equal( + browseError.message, + `Failed to browse filesystem path './missing-browse/child' from '${workspaceDir}'.`, + ); + assert.equal(browseError.cwd, workspaceDir); + assert.equal(browseError.partialPath, "./missing-browse/child"); + assert.equal(browseError.failure, "read_directory_failed"); + assert.equal(browseError.parentPath, missingBrowseParent); + assert.isDefined(browseError.cause); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("reports workspace root stat failures without relabeling them as missing", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const blockedRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-stat-error-", + }); + const workspaceRoot = path.join(blockedRoot, "workspace"); + yield* fs.makeDirectory(workspaceRoot); + yield* fs.chmod(blockedRoot, 0o000); + + const result = yield* Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + return yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsListEntries]({ cwd: workspaceRoot }).pipe(Effect.result), + ), + ); + }).pipe(Effect.ensuring(fs.chmod(blockedRoot, 0o700).pipe(Effect.ignore))); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectListEntriesError") { + assert.fail("Expected a ProjectListEntriesError"); + } + const error = result.failure; + assert.equal(error.failure, "workspace_root_stat_failed"); + assert.equal(error.normalizedCwd, workspaceRoot); + assert.equal(error.detail, "validate-existing"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4556,12 +4656,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ).pipe(Effect.result), ); - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "ProjectWriteFileError"); + if (result._tag !== "Failure" || result.failure._tag !== "ProjectWriteFileError") { + assert.fail("Expected a ProjectWriteFileError"); + } + const writeError = result.failure; assert.equal( - result.failure.message, - "Workspace file path must stay within the project root.", + writeError.message, + `Failed to write workspace file '../escape.txt' in '${workspaceDir}'.`, ); + assert.equal(writeError.cwd, workspaceDir); + assert.equal(writeError.relativePath, "../escape.txt"); + assert.equal(writeError.failure, "workspace_path_outside_root"); + assert.isDefined(writeError.cause); + assert.notProperty(writeError, "contents"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4595,8 +4702,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc shell.openInEditor errors", () => Effect.gen(function* () { - const externalLauncherError = new ExternalLauncherError({ - message: "Editor command not found: cursor", + const externalLauncherError = new ExternalLauncherCommandNotFoundError({ + editor: "cursor", + command: "cursor", }); yield* buildAppUnderTest({ layers: { @@ -5923,6 +6031,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { () => Effect.gen(function* () { const dispatchedCommands: Array = []; + const bootstrapGitOperations: string[] = []; const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, @@ -5941,17 +6050,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("fetch"); + }), + ); + const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567"; + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("resolve-remote-commit"); + return { + commitSha: fetchedOriginCommit, + remoteRefName: "origin/main", + }; + }), + ); const createWorktree = vi.fn( - (_: Parameters[0]) => - Effect.succeed({ - worktree: { - refName: "t3code/bootstrap-refName", - path: "/tmp/bootstrap-worktree", - }, + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("create-worktree"); + return { + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }; }), ); const runForThread = vi.fn( - (_: Parameters[0]) => + ( + _: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => Effect.succeed({ status: "started" as const, scriptId: "setup", @@ -5964,6 +6097,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + fetchRemote, + resolveRemoteTrackingCommit, createWorktree, }, vcsStatusBroadcaster: { @@ -6015,6 +6150,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", baseBranch: "main", branch: "t3code/bootstrap-refName", + startFromOrigin: true, }, runSetupScript: true, }, @@ -6036,10 +6172,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", - refName: "main", + refName: fetchedOriginCommit, newRefName: "t3code/bootstrap-refName", + baseRefName: "main", path: null, }); + assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.deepEqual(resolveRemoteTrackingCommit.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + fallbackRemoteName: "origin", + }); + assert.deepEqual(bootstrapGitOperations, [ + "fetch", + "resolve-remote-commit", + "create-worktree", + ]); assert.deepEqual(runForThread.mock.calls[0]?.[0], { threadId: ThreadId.make("thread-bootstrap"), projectId: defaultProjectId, @@ -6068,7 +6219,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( - (_: Parameters[0]) => + (_: Parameters[0]) => Effect.succeed({ worktree: { refName: "t3code/bootstrap-refName", @@ -6077,8 +6228,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); const runForThread = vi.fn( - (_: Parameters[0]) => - Effect.fail(new ProjectSetupScriptRunnerError({ message: "pty unavailable" })), + ( + input: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => + Effect.fail( + new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ + threadId: input.threadId, + worktreePath: input.worktreePath, + operation: "openTerminal", + cause: { message: "pty unavailable" }, + }), + ), ); yield* buildAppUnderTest({ @@ -6162,7 +6324,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( - (_: Parameters[0]) => + (_: Parameters[0]) => Effect.succeed({ worktree: { refName: "t3code/bootstrap-refName", @@ -6171,7 +6333,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); const runForThread = vi.fn( - (_: Parameters[0]) => + ( + _: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => Effect.succeed({ status: "started" as const, scriptId: "setup", @@ -6281,7 +6447,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( - (_: Parameters[0]) => + (_: Parameters[0]) => Effect.die(new Error("worktree exploded")), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3d231253240..81f8428c0c0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; -import { ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, @@ -16,32 +16,32 @@ import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; -import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; -import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; -import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; +import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; -import { KeybindingsLive } from "./keybindings.ts"; -import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; +import * as Keybindings from "./keybindings.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; @@ -51,12 +51,12 @@ import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletion import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; -import { ServerSettingsLive } from "./serverSettings.ts"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; @@ -67,10 +67,10 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; -import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; +import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; -import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; import { LaunchEnvLive } from "./launchEnv/Layers/LaunchEnvLive.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -105,32 +105,32 @@ const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { - const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts")); - return BunPTY.layer; + const BunPtyAdapter = yield* Effect.promise(() => import("./terminal/BunPtyAdapter.ts")); + return BunPtyAdapter.layer; } else { - const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts")); - return NodePTY.layer; + const NodePtyAdapter = yield* Effect.promise(() => import("./terminal/NodePtyAdapter.ts")); + return NodePtyAdapter.layer; } }), ); const RelayClientLive = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return RelayClient.layerCloudflared({ baseDir: config.baseDir }); }), ); const HttpServerLive = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; if (typeof Bun !== "undefined") { const BunHttpServer = yield* Effect.promise( () => import("@effect/platform-bun/BunHttpServer"), ); return BunHttpServer.layer({ port: config.port, - ...(config.host ? { hostname: config.host } : {}), + hostname: config.host ?? "127.0.0.1", gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, }); } else { @@ -139,7 +139,7 @@ const HttpServerLive = Layer.unwrap( Effect.promise(() => import("node:http")), ]); return NodeHttpServer.layer(NodeHttp.createServer, { - host: config.host, + host: config.host ?? "127.0.0.1", port: config.port, gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, }); @@ -168,9 +168,8 @@ const LaunchEnvLayerLive = LaunchEnvLive.pipe( const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); -const TerminalLayerLive = TerminalManagerLive.pipe( +const TerminalLayerLive = TerminalManager.layer.pipe( Layer.provide(PtyAdapterLive), - Layer.provide(LaunchEnvLayerLive), Layer.provide(PortScannerLayerLive), ); @@ -190,7 +189,7 @@ const ReactorLayerLive = Layer.empty.pipe( ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter @@ -217,7 +216,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay ); const GitManagerLayerLive = GitManager.layer.pipe( - Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(ProjectSetupScriptRunner.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -254,8 +253,8 @@ const VcsLayerLive = Layer.empty.pipe( ); const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), + Layer.provideMerge(CheckpointDiffQuery.layer), + Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); const PreviewLayerLive = Layer.empty.pipe( @@ -263,21 +262,21 @@ const PreviewLayerLive = Layer.empty.pipe( Layer.provideMerge(PortScannerLayerLive), ); -const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive)); +const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer)); -const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), +const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe( + Layer.provide(WorkspacePaths.layer), Layer.provide(WorkspaceEntriesLayerLive), ); const WorkspaceLayerLive = Layer.mergeAll( - WorkspacePathsLive, + WorkspacePaths.layer, WorkspaceEntriesLayerLive, WorkspaceFileSystemLayerLive, ); -const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe( - Layer.provide(WorkspacePathsLive), +const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), ); const AuthLayerLive = EnvironmentAuth.layer.pipe( @@ -307,7 +306,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), - Layer.provideMerge(KeybindingsLive), + Layer.provideMerge(Keybindings.layer), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` @@ -320,18 +319,18 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same // logger instances. - Layer.provideMerge(ProviderEventLoggersLive), + Layer.provideMerge(ProviderEventLoggers.ProviderEventLoggersLive), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. - Layer.provideMerge(OpenCodeRuntimeLive), - Layer.provideMerge(ServerSettingsLive), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), - Layer.provideMerge(RepositoryIdentityResolverLive), - Layer.provideMerge(ServerEnvironmentLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(ServerEnvironment.layer), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -347,13 +346,13 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(ProcessDiagnostics.layer), Layer.provideMerge(ProcessResourceMonitor.layer), Layer.provideMerge(TraceDiagnostics.layer), - Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), - Layer.provideMerge(ServerLifecycleEventsLive), + Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); -const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( +const RuntimeServicesLive = ServerRuntimeStartup.layer.pipe( Layer.provideMerge(RuntimeDependenciesLive), ); @@ -376,14 +375,14 @@ export const makeRoutesLayer = Layer.mergeAll( export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; yield* fixPath(); const httpListeningLayer = Layer.effectDiscard( Effect.gen(function* () { yield* HttpServer.HttpServer; - const startup = yield* ServerRuntimeStartup; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; yield* startup.markHttpListening; }), ); @@ -501,7 +500,7 @@ export const makeServerLayer = Layer.unwrap( cloudDesiredLinkReconcileLayer, ); - const serverConfigLayer = Layer.succeed(ServerConfig, config); + const serverConfigLayer = Layer.succeed(ServerConfig.ServerConfig, config); return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive.pipe(Layer.provideMerge(serverConfigLayer))), Layer.provideMerge(serverRelayBrokerTracingLayer), diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 14fbba9e238..4f7b75fb4bd 100644 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ b/apps/server/src/serverLifecycleEvents.test.ts @@ -4,13 +4,13 @@ import { assertTrue } from "@effect/vitest/utils"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; it.effect( "publishes lifecycle events without subscribers and snapshots the latest welcome/ready", () => Effect.gen(function* () { - const lifecycleEvents = yield* ServerLifecycleEvents; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const environment = { environmentId: EnvironmentId.make("environment-test"), label: "Test environment", @@ -49,5 +49,5 @@ it.effect( const snapshot = yield* lifecycleEvents.snapshot; assert.equal(snapshot.sequence, 2); assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), ["ready", "welcome"]); - }).pipe(Effect.provide(ServerLifecycleEventsLive)), + }).pipe(Effect.provide(ServerLifecycleEvents.layer)), ); diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 88661b1593a..855d03490ef 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -1,9 +1,9 @@ import type { ServerLifecycleStreamEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; -import * as Context from "effect/Context"; import * as Stream from "effect/Stream"; type LifecycleEventInput = @@ -15,44 +15,41 @@ interface SnapshotState { readonly events: ReadonlyArray; } -export interface ServerLifecycleEventsShape { - readonly publish: (event: LifecycleEventInput) => Effect.Effect; - readonly snapshot: Effect.Effect; - readonly stream: Stream.Stream; -} - export class ServerLifecycleEvents extends Context.Service< ServerLifecycleEvents, - ServerLifecycleEventsShape + { + readonly publish: (event: LifecycleEventInput) => Effect.Effect; + readonly snapshot: Effect.Effect; + readonly stream: Stream.Stream; + } >()("t3/serverLifecycleEvents") {} -export const ServerLifecycleEventsLive = Layer.effect( - ServerLifecycleEvents, - Effect.gen(function* () { - const pubsub = yield* PubSub.unbounded(); - const state = yield* Ref.make({ - sequence: 0, - events: [], - }); +const make = Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const state = yield* Ref.make({ + sequence: 0, + events: [], + }); + + return { + publish: (event) => + Ref.modify(state, (current) => { + const nextSequence = current.sequence + 1; + const nextEvent = { + ...event, + sequence: nextSequence, + } satisfies ServerLifecycleStreamEvent; + const nextEvents = + nextEvent.type === "welcome" + ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")] + : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")]; + return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; + }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), + snapshot: Ref.get(state), + get stream() { + return Stream.fromPubSub(pubsub); + }, + } satisfies ServerLifecycleEvents["Service"]; +}); - return { - publish: (event) => - Ref.modify(state, (current) => { - const nextSequence = current.sequence + 1; - const nextEvent = { - ...event, - sequence: nextSequence, - } satisfies ServerLifecycleStreamEvent; - const nextEvents = - nextEvent.type === "welcome" - ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")] - : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")]; - return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; - }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), - snapshot: Ref.get(state), - get stream() { - return Stream.fromPubSub(pubsub); - }, - } satisfies ServerLifecycleEventsShape; - }), -); +export const layer = Layer.effect(ServerLifecycleEvents, make); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 90eebe33820..e331f0cd4d6 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -10,24 +10,14 @@ import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import { ServerConfig } from "./config.ts"; -import { - OrchestrationEngineService, - type OrchestrationEngineShape, -} from "./orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; -import { - getAutoBootstrapDefaultModelSelection, - launchStartupHeartbeat, - makeCommandGate, - resolveAutoBootstrapWelcomeTargets, - resolveWelcomeBase, - ServerRuntimeStartupError, -} from "./serverRuntimeStartup.ts"; +import * as ServerConfig from "./config.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; it("uses the canonical Codex default for auto-bootstrapped model selection", () => { - assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { + assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); @@ -37,7 +27,7 @@ it.effect("enqueueCommand waits for readiness and then drains queued work", () = Effect.scoped( Effect.gen(function* () { const executionCount = yield* Ref.make(0); - const commandGate = yield* makeCommandGate; + const commandGate = yield* ServerRuntimeStartup.makeCommandGate; const queuedCommandFiber = yield* commandGate .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1)) @@ -58,7 +48,7 @@ it.effect("enqueueCommand waits for readiness and then drains queued work", () = it.effect("enqueueCommand fails queued work when readiness fails", () => Effect.scoped( Effect.gen(function* () { - const commandGate = yield* makeCommandGate; + const commandGate = yield* ServerRuntimeStartup.makeCommandGate; const failure = yield* Deferred.make(); const queuedCommandFiber = yield* commandGate @@ -66,13 +56,16 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => .pipe(Effect.forkScoped); yield* commandGate.failCommandReady( - new ServerRuntimeStartupError({ - message: "startup failed", + new ServerRuntimeStartup.ServerRuntimeStartupError({ + mode: "web", + host: "127.0.0.1", + port: 3773, + cause: new Error("test startup failure"), }), ); const error = yield* Effect.flip(Fiber.join(queuedCommandFiber)); - assert.equal(error.message, "startup failed"); + assert.equal(error.message, "Server runtime startup failed before command readiness."); }), ), ); @@ -82,8 +75,8 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa Effect.gen(function* () { const releaseCounts = yield* Deferred.make(); - yield* launchStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery, { + yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -104,7 +97,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), }), - Effect.provideService(AnalyticsService, { + Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, flush: Effect.void, }), @@ -115,8 +108,8 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { - const welcome = yield* resolveWelcomeBase.pipe( - Effect.provideService(ServerConfig, { + const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( + Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", } as never), ); @@ -134,12 +127,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa return Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); - const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(ServerConfig, { + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, } as never), - Effect.provideService(ProjectionSnapshotQuery, { + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -152,7 +145,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa id: bootstrapProjectId, title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -166,14 +159,14 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), }), - Effect.provideService(OrchestrationEngineService, { + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - } satisfies OrchestrationEngineShape), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -188,12 +181,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); - const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(ServerConfig, { + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, } as never), - Effect.provideService(ProjectionSnapshotQuery, { + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -208,14 +201,14 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), }), - Effect.provideService(OrchestrationEngineService, { + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - } satisfies OrchestrationEngineShape), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -236,12 +229,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa }); const dispatchCalls = yield* Ref.make>([]); - const error = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(ServerConfig, { + const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, } as never), - Effect.provideService(ProjectionSnapshotQuery, { + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -256,14 +249,14 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), }), - Effect.provideService(OrchestrationEngineService, { + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - } satisfies OrchestrationEngineShape), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provideService(Crypto.Crypto, { ...crypto, randomUUIDv4: Effect.fail(uuidError), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 363133031d8..11091744979 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -7,8 +7,10 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; -import * as Data from "effect/Data"; +import * as Console from "effect/Console"; +import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -17,23 +19,21 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as Context from "effect/Context"; -import * as Console from "effect/Console"; -import * as DateTime from "effect/DateTime"; -import { ServerConfig } from "./config.ts"; -import { Keybindings } from "./keybindings.ts"; +import * as ServerConfig from "./config.ts"; +import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor.ts"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; -import { ServerSettingsService } from "./serverSettings.ts"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; +import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -41,22 +41,29 @@ import { issueHeadlessServeAccessInfo, } from "./startupAccess.ts"; -export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export interface ServerRuntimeStartupShape { - readonly awaitCommandReady: Effect.Effect; - readonly markHttpListening: Effect.Effect; - readonly enqueueCommand: ( - effect: Effect.Effect, - ) => Effect.Effect; +export class ServerRuntimeStartupError extends Schema.TaggedErrorClass()( + "ServerRuntimeStartupError", + { + mode: ServerConfig.RuntimeMode, + host: Schema.NullOr(Schema.String), + port: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Server runtime startup failed before command readiness."; + } } export class ServerRuntimeStartup extends Context.Service< ServerRuntimeStartup, - ServerRuntimeStartupShape + { + readonly awaitCommandReady: Effect.Effect; + readonly markHttpListening: Effect.Effect; + readonly enqueueCommand: ( + effect: Effect.Effect, + ) => Effect.Effect; + } >()("t3/serverRuntimeStartup") {} interface QueuedCommand { @@ -124,8 +131,8 @@ export const makeCommandGate = Effect.gen(function* () { }); export const recordStartupHeartbeat = Effect.gen(function* () { - const analytics = yield* AnalyticsService; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const analytics = yield* AnalyticsService.AnalyticsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const { threadCount, projectCount } = yield* projectionSnapshotQuery.getCounts().pipe( Effect.catch((cause) => @@ -160,7 +167,7 @@ export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ }); export const resolveWelcomeBase = Effect.gen(function* () { - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); const projectName = segments[segments.length - 1] ?? "project"; @@ -173,9 +180,9 @@ export const resolveWelcomeBase = Effect.gen(function* () { export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const randomUUID = crypto.randomUUIDv4; - const serverConfig = yield* ServerConfig; - const projectionReadModelQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; + const serverConfig = yield* ServerConfig.ServerConfig; + const projectionReadModelQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const path = yield* Path.Path; let bootstrapProjectId: ProjectId | undefined; @@ -243,7 +250,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { }); const resolveStartupBrowserTarget = Effect.gen(function* () { - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const localUrl = `http://localhost:${serverConfig.port}`; const bindUrl = @@ -260,7 +267,7 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { const maybeOpenBrowser = (target: string) => Effect.gen(function* () { - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; if (serverConfig.noBrowser) { return; } @@ -281,14 +288,14 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) Effect.withSpan(`server.startup.${phase}`), ); -export const makeServerRuntimeStartup = Effect.gen(function* () { - const serverConfig = yield* ServerConfig; - const keybindings = yield* Keybindings; - const orchestrationReactor = yield* OrchestrationReactor; - const providerSessionReaper = yield* ProviderSessionReaper; - const lifecycleEvents = yield* ServerLifecycleEvents; - const serverSettings = yield* ServerSettingsService; - const serverEnvironment = yield* ServerEnvironment; +export const make = Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const keybindings = yield* Keybindings.Keybindings; + const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; + const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const crypto = yield* Crypto.Crypto; const commandGate = yield* makeCommandGate; @@ -320,7 +327,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { Effect.catch((error) => Effect.logWarning("failed to start server settings runtime", { path: error.settingsPath, - detail: error.detail, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, cause: error.cause, }), ), @@ -409,7 +418,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const startupExit = yield* Effect.exit(startup); if (Exit.isFailure(startupExit)) { const error = new ServerRuntimeStartupError({ - message: "Server runtime startup failed before command readiness.", + mode: serverConfig.mode, + host: serverConfig.host ?? null, + port: serverConfig.port, cause: startupExit.cause, }); yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); @@ -461,10 +472,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { awaitCommandReady: commandGate.awaitCommandReady, markHttpListening: Deferred.succeed(httpListening, undefined), enqueueCommand: commandGate.enqueueCommand, - } satisfies ServerRuntimeStartupShape; + } satisfies ServerRuntimeStartup["Service"]; }); -export const ServerRuntimeStartupLive = Layer.effect( - ServerRuntimeStartup, - makeServerRuntimeStartup, -); +export const layer = Layer.effect(ServerRuntimeStartup, make); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts new file mode 100644 index 00000000000..749fd3062e9 --- /dev/null +++ b/apps/server/src/serverRuntimeState.test.ts @@ -0,0 +1,167 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 References from "effect/References"; +import * as Schema from "effect/Schema"; + +import * as ServerRuntimeState from "./serverRuntimeState.ts"; + +const isServerRuntimeStateError = Schema.is(ServerRuntimeState.ServerRuntimeStateError); + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} + +describe("serverRuntimeState", () => { + it.effect("persists and reads the runtime state", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "runtime", "server.json"); + const state: ServerRuntimeState.PersistedServerRuntimeState = { + version: 1, + pid: 123, + host: "127.0.0.1", + port: 4_971, + origin: "http://127.0.0.1:4971", + startedAt: "2026-06-20T00:00:00.000Z", + }; + + yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state }); + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.deepEqual(Option.getOrThrow(restored), state); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("treats a missing runtime state file as absent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState( + path.join(root, "missing.json"), + ); + + assert.isTrue(Option.isNone(restored)); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves malformed state decode failures", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "server.json"); + yield* fileSystem.writeFileString(statePath, "{not json"); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.isTrue(Option.isNone(restored)); + assert.equal(logs[0]?.message, `Failed to decode server runtime state at ${statePath}.`); + const error = logs[0]?.annotations.cause; + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "decode"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to decode server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "SchemaError" }); + } + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + + it.effect("preserves runtime state read failures", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "server.json"); + yield* fileSystem.makeDirectory(statePath); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.isTrue(Option.isNone(restored)); + assert.equal(logs[0]?.message, `Failed to read server runtime state at ${statePath}.`); + const error = logs[0]?.annotations.cause; + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "read"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to read server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + } + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + + it.effect("preserves runtime state persistence failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const blockedDirectory = path.join(root, "not-a-directory"); + const statePath = path.join(blockedDirectory, "server.json"); + yield* fileSystem.writeFileString(blockedDirectory, "blocked"); + + const error = yield* ServerRuntimeState.persistServerRuntimeState({ + path: statePath, + state: { + version: 1, + pid: 123, + port: 4_971, + origin: "http://127.0.0.1:4971", + startedAt: "2026-06-20T00:00:00.000Z", + }, + }).pipe(Effect.flip); + + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "persist"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to persist server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 996f9a2bfc9..329b000369a 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "./atomicWrite.ts"; -import { type ServerConfigShape } from "./config.ts"; +import type * as ServerConfig from "./config.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; export const PersistedServerRuntimeState = Schema.Struct({ @@ -18,12 +18,25 @@ export const PersistedServerRuntimeState = Schema.Struct({ }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; +export class ServerRuntimeStateError extends Schema.TaggedErrorClass()( + "ServerRuntimeStateError", + { + operation: Schema.Literals(["persist", "read", "decode", "clear"]), + statePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} server runtime state at ${this.statePath}.`; + } +} + const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( Schema.fromJsonString(PersistedServerRuntimeState), ); const runtimeOriginForConfig = ( - config: Pick, + config: Pick, port: number, ): PersistedServerRuntimeState["origin"] => { const hostname = @@ -32,7 +45,7 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ @@ -51,27 +64,90 @@ export const persistServerRuntimeState = (input: { writeFileStringAtomically({ filePath: input.path, contents: `${JSON.stringify(input.state)}\n`, - }); + }).pipe( + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "persist", + statePath: input.path, + cause, + }), + ), + ); export const clearPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.remove(path, { force: true }).pipe(Effect.ignore({ log: true })); + yield* fs.remove(path, { force: true }).pipe( + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "clear", + statePath: path, + cause, + }), + ), + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + ), + }), + ); }); export const readPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); - if (!exists) { + const raw = yield* fs.readFileString(path).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + new ServerRuntimeStateError({ + operation: "read", + statePath: path, + cause, + }), + ), + onSuccess: (contents) => Effect.succeed(Option.some(contents)), + }), + ); + if (Option.isNone(raw)) { return Option.none(); } - const raw = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => "")); - const trimmed = raw.trim(); + const trimmed = raw.value.trim(); if (trimmed.length === 0) { return Option.none(); } - return yield* decodePersistedServerRuntimeState(trimmed).pipe(Effect.option); - }); + return yield* decodePersistedServerRuntimeState(trimmed).pipe( + Effect.map(Option.some), + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "decode", + statePath: path, + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + Effect.as(Option.none()), + ), + }), + ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d24f2ee2826..504d99e18de 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -12,15 +12,18 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import { ServerConfig } from "./config.ts"; -import { ServerSettingsLive, ServerSettingsService } from "./serverSettings.ts"; +import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as ServerConfig from "./config.ts"; +import * as ServerSettingsModule from "./serverSettings.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const makeServerSettingsLayer = () => - ServerSettingsLive.pipe( + ServerSettingsModule.layer.pipe( + Layer.provide(ServerSecretStore.layer), Layer.provideMerge( Layer.fresh( ServerConfig.layerTest(process.cwd(), { @@ -30,7 +33,63 @@ const makeServerSettingsLayer = () => ), ); +const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => + Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.fail(cause), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: () => Effect.void, + }), + ); + it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("preserves context when reading a provider environment secret fails", () => { + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "provider environment secret", + description: "Secret backend unavailable.", + }); + const cause = new ServerSecretStore.SecretStoreReadError({ + resource: "provider environment secret", + cause: platformCause, + }); + const configLayer = Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-secret-failure-test-", + }), + ); + const settingsLayer = ServerSettingsModule.layer.pipe( + Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(configLayer), + ); + + return Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"OPENROUTER_API_KEY","value":"","sensitive":true,"valueRedacted":true}],"config":{}}}}', + ); + + const error = yield* Effect.flip(serverSettings.getSettings); + + assert.deepInclude(error, { + _tag: "ServerSettingsError", + operation: "read-secret", + providerInstanceId: "codex_personal", + environmentVariable: "OPENROUTER_API_KEY", + }); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(settingsLayer)); + }); + it.effect("decodes nested settings patches", () => Effect.gen(function* () { assert.deepEqual( @@ -77,7 +136,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("deep merges nested settings updates without dropping siblings", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* serverSettings.updateSettings({ providers: { @@ -145,7 +204,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; // Start with Claude text generation selection yield* serverSettings.updateSettings({ @@ -183,7 +242,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("preserves custom provider instance text generation selections", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const next = yield* serverSettings.updateSettings({ providerInstances: { @@ -210,7 +269,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { "uses explicit provider instance enabled state over legacy provider enabled state", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const instanceId = ProviderInstanceId.make("claude_openrouter"); const next = yield* serverSettings.updateSettings({ @@ -241,7 +300,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("preserves enabled text generation selections for non-built-in drivers", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const instanceId = ProviderInstanceId.make("openrouter_text"); const next = yield* serverSettings.updateSettings({ @@ -267,7 +326,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("drops stale text generation options when resetting model selection", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* serverSettings.updateSettings({ textGenerationModelSelection: { @@ -300,7 +359,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("replaces provider instance maps when clearing optional fields", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const codexId = ProviderInstanceId.make("codex"); yield* serverSettings.updateSettings({ @@ -337,7 +396,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("trims provider path settings when updates are applied", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const next = yield* serverSettings.updateSettings({ providers: { @@ -382,7 +441,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("trims observability settings when updates are applied", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const next = yield* serverSettings.updateSettings({ addProjectBaseDirectory: " ~/Development ", @@ -402,7 +461,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("defaults blank binary paths to provider executables", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const next = yield* serverSettings.updateSettings({ providers: { @@ -422,8 +481,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("writes only non-default server settings to disk", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development", @@ -469,8 +528,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("stores sensitive provider instance environment values outside settings.json", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const instanceId = ProviderInstanceId.make("codex_personal"); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0e126604b4a..4119a72640f 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -26,25 +26,25 @@ import { type ServerSettingsPatch, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as Equal from "effect/Equal"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; +import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; -import * as Context from "effect/Context"; import * as Stream from "effect/Stream"; -import * as Cause from "effect/Cause"; -import * as Semaphore from "effect/Semaphore"; import { writeFileStringAtomically } from "./atomicWrite.ts"; -import { ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; @@ -66,7 +66,7 @@ const normalizeServerSettings = ( (cause) => new ServerSettingsError({ settingsPath: "", - detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + operation: "normalize", cause, }), ), @@ -108,59 +108,60 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS return { ...settings, providerInstances }; } -export interface ServerSettingsShape { - /** Start the settings runtime and attach file watching. */ - readonly start: Effect.Effect; +export class ServerSettingsService extends Context.Service< + ServerSettingsService, + { + /** Start the settings runtime and attach file watching. */ + readonly start: Effect.Effect; - /** Await settings runtime readiness. */ - readonly ready: Effect.Effect; + /** Await settings runtime readiness. */ + readonly ready: Effect.Effect; - /** Read the current settings. */ - readonly getSettings: Effect.Effect; + /** Read the current settings. */ + readonly getSettings: Effect.Effect; - /** Patch settings and persist. Returns the new full settings object. */ - readonly updateSettings: ( - patch: ServerSettingsPatch, - ) => Effect.Effect; + /** Patch settings and persist. Returns the new full settings object. */ + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => Effect.Effect; - /** Stream of settings change events. */ - readonly streamChanges: Stream.Stream; -} - -export class ServerSettingsService extends Context.Service< - ServerSettingsService, - ServerSettingsShape + /** Stream of settings change events. */ + readonly streamChanges: Stream.Stream; + } >()("t3/serverSettings/ServerSettingsService") { - static readonly layerTest = (overrides: DeepPartial = {}) => - Layer.effect( - ServerSettingsService, - Effect.gen(function* () { - const { automaticGitFetchInterval, ...overridesForMerge } = overrides; - const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); - const initialSettings = yield* normalizeServerSettings({ - ...merged, - ...(automaticGitFetchInterval !== undefined - ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } - : {}), - }); - const currentSettingsRef = yield* Ref.make(initialSettings); - - return { - start: Effect.void, - ready: Effect.void, - getSettings: Ref.get(currentSettingsRef), - updateSettings: (patch) => - Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), - Effect.flatMap(normalizeServerSettings), - Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), - ), - streamChanges: Stream.empty, - } satisfies ServerSettingsShape; - }), - ); + /** @deprecated Import and use `layerTest` from this module. */ + static readonly layerTest = (overrides: DeepPartial = {}) => layerTest(overrides); } +const makeTest = (overrides: DeepPartial = {}) => + Effect.gen(function* () { + const { automaticGitFetchInterval, ...overridesForMerge } = overrides; + const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); + const initialSettings = yield* normalizeServerSettings({ + ...merged, + ...(automaticGitFetchInterval !== undefined + ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } + : {}), + }); + const currentSettingsRef = yield* Ref.make(initialSettings); + + return { + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(currentSettingsRef), + updateSettings: (patch) => + Ref.get(currentSettingsRef).pipe( + Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), + Effect.flatMap(normalizeServerSettings), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + ), + streamChanges: Stream.empty, + } satisfies ServerSettingsService["Service"]; + }); + +export const layerTest = (overrides: DeepPartial = {}) => + Layer.effect(ServerSettingsService, makeTest(overrides)); + const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); @@ -254,8 +255,8 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow return Object.is(current, defaults) ? undefined : current; } -const makeServerSettings = Effect.gen(function* () { - const { settingsPath } = yield* ServerConfig; +const make = Effect.gen(function* () { + const { settingsPath } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -275,7 +276,7 @@ const makeServerSettings = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to check settings file existence", + operation: "check-exists", cause, }), ), @@ -286,7 +287,7 @@ const makeServerSettings = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to read settings file", + operation: "read-file", cause, }), ), @@ -303,6 +304,7 @@ const makeServerSettings = Effect.gen(function* () { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(decoded.cause), + cause: decoded.cause, }); return DEFAULT_SERVER_SETTINGS; } @@ -316,13 +318,6 @@ const makeServerSettings = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); - const toSettingsError = (detail: string, cause: unknown) => - new ServerSettingsError({ - settingsPath, - detail, - cause, - }); - const materializeProviderEnvironmentSecrets = ( settings: ServerSettings, ): Effect.Effect => @@ -341,16 +336,20 @@ const makeServerSettings = Effect.gen(function* () { const secret = yield* secretStore .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) .pipe( - Effect.mapError((cause) => - toSettingsError( - `failed to read sensitive environment variable ${variable.name}`, - cause, - ), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), ), ); environment.push({ ...variable, - value: secret ? textDecoder.decode(secret) : "", + value: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }); } providerInstances[instanceId] = { @@ -380,13 +379,18 @@ const makeServerSettings = Effect.gen(function* () { for (const variable of instance.environment) { const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (!variable.sensitive) { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to remove environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); environment.push(redactProviderEnvironmentVariable(variable)); continue; } @@ -394,22 +398,32 @@ const makeServerSettings = Effect.gen(function* () { nextSecretKeys.add(secretName); if (!variable.valueRedacted) { if (variable.value.length > 0) { - yield* secretStore - .set(secretName, textEncoder.encode(variable.value)) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to persist environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.set(secretName, textEncoder.encode(variable.value)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); environment.push({ ...variable, value: "", valueRedacted: true }); } else { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to remove environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); const { valueRedacted: _omit, ...rest } = variable; environment.push(rest); } @@ -429,16 +443,18 @@ const makeServerSettings = Effect.gen(function* () { if (!variable.sensitive) continue; const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (nextSecretKeys.has(secretName)) continue; - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError( - `failed to remove stale environment secret ${variable.name}`, + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-stale-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, cause, - ), - ), - ); + }), + ), + ); } } @@ -466,7 +482,7 @@ const makeServerSettings = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to write settings file", + operation: "write-file", cause, }), ), @@ -490,7 +506,7 @@ const makeServerSettings = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to prepare settings directory", + operation: "prepare-directory", cause, }), ), @@ -569,7 +585,10 @@ const makeServerSettings = Effect.gen(function* () { materializeProviderEnvironmentSecrets(settings).pipe( Effect.catch((error: ServerSettingsError) => Effect.logWarning("failed to materialize provider environment secrets", { - detail: error.detail, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, }).pipe(Effect.as(settings)), ), ), @@ -577,9 +596,7 @@ const makeServerSettings = Effect.gen(function* () { Stream.map(resolveTextGenerationProvider), ); }, - } satisfies ServerSettingsShape; + } satisfies ServerSettingsService["Service"]; }); -export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe( - Layer.provide(ServerSecretStore.layer), -); +export const layer = Layer.effect(ServerSettingsService, make); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f3078fcd06c..1cd4b388552 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -4,7 +4,9 @@ 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 PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -17,7 +19,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const supportLayer = Layer.mergeAll( Layer.mock(VcsProcess.VcsProcess)({ @@ -329,4 +331,78 @@ describe("AzureDevOpsCli.layer", () => { }); }).pipe(Effect.provide(layer)), ); + + it.effect("preserves VCS causes without copying upstream details into messages", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "AzureDevOpsCli.execute", + command: "az repos list --organization sensitive-upstream-detail", + cwd: "/repo", + exitCode: 1, + detail: "sensitive-upstream-detail", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.operation, "execute"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.argumentCount, 2); + assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("sensitive-upstream-detail"), false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("does not report a missing working directory as a missing Azure CLI", () => + Effect.gen(function* () { + const cwd = "/missing/repo"; + const platformCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + syscall: "chdir", + pathOrDescriptor: cwd, + }); + const cause = new VcsProcessSpawnError({ + operation: "AzureDevOpsCli.execute", + command: "az", + cwd, + argumentCount: 2, + cause: platformCause, + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.cwd, cwd); + assert.strictEqual(error.cause, cause); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps invalid pull request output diagnostics structured", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.outputLength, 8); + assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); + assert.exists(error.cause); + assert.strictEqual( + error.message, + "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", + ); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index e39ce9f0100..609efe4df4c 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,161 +1,254 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + type NormalizedAzureDevOpsPullRequestRecord, +} from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( - "AzureDevOpsCliError", - { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, +const azureDevOpsCommandErrorFields = { + operation: Schema.Literal("execute"), + command: Schema.Literal("az"), + cwd: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsCliUnavailableError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliUnavailableError", + azureDevOpsCommandErrorFields, ) { + get detail(): string { + return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; + } + override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } -export interface AzureDevOpsRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; +export class AzureDevOpsCliAuthenticationError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliAuthenticationError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } } -export interface AzureDevOpsCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect< - ReadonlyArray, - AzureDevOpsCliError - >; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect< - AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, - AzureDevOpsCliError - >; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly remoteName?: string; - }) => Effect.Effect; +export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestNotFoundError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } } -export class AzureDevOpsCli extends Context.Service()( - "t3/sourceControl/AzureDevOpsCli", -) {} +export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass()( + "AzureDevOpsCommandFailedError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI command failed."; + } -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } - return String(error); + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "az"; + readonly cwd: string; + readonly argumentCount: number; + }, + cause: VcsError, + ): AzureDevOpsCliError { + const fields = { ...context, cause }; + + if ( + cause._tag === "VcsProcessSpawnError" && + cause.cause instanceof PlatformError.PlatformError && + cause.cause.reason._tag === "NotFound" && + cause.cause.reason.pathOrDescriptor !== context.cwd && + cause.cause.reason.syscall !== "chdir" + ) { + return new AzureDevOpsCliUnavailableError(fields); + } + + if (cause._tag === "VcsProcessExitError") { + if (cause.failureKind === "authentication") { + return new AzureDevOpsCliAuthenticationError(fields); + } + if (cause.failureKind === "not-found") { + return new AzureDevOpsPullRequestNotFoundError(fields); + } + } + + return new AzureDevOpsCommandFailedError(fields); + } } -function normalizeAzureDevOpsCliError( - operation: "execute", - error: VcsError | unknown, -): AzureDevOpsCliError { - const text = errorText(error); - const lower = text.toLowerCase(); - - if (lower.includes("command not found: az") || lower.includes("enoent")) { - return new AzureDevOpsCliError({ - operation, - detail: - "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", - cause: error, - }); +const azureDevOpsDecodeErrorFields = { + command: Schema.Literal("az"), + cwd: Schema.String, + outputLength: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestListDecodeError", + { + operation: Schema.Literal("listPullRequests"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid PR list JSON."; } - if ( - lower.includes("az devops login") || - lower.includes("please run az login") || - lower.includes("not logged in") || - lower.includes("authentication failed") || - lower.includes("unauthorized") - ) { - return new AzureDevOpsCliError({ - operation, - detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", - cause: error, - }); + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } +} - if ( - lower.includes("pull request") && - (lower.includes("not found") || lower.includes("does not exist")) - ) { - return new AzureDevOpsCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); +export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestDecodeError", + { + operation: Schema.Literal("getPullRequest"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid pull request JSON."; } - return new AzureDevOpsCliError({ - operation, - detail: text, - cause: error, - }); + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ + "getRepositoryCloneUrls", + "getDefaultBranch", + "createRepository", +]); + +export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsRepositoryDecodeError", + { + operation: AzureDevOpsRepositoryDecodeOperation, + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid repository JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export const AzureDevOpsCliError = Schema.Union([ + AzureDevOpsCliUnavailableError, + AzureDevOpsCliAuthenticationError, + AzureDevOpsPullRequestNotFoundError, + AzureDevOpsCommandFailedError, + AzureDevOpsPullRequestListDecodeError, + AzureDevOpsPullRequestDecodeError, + AzureDevOpsRepositoryDecodeError, +]); +export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; + +export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError); + +export interface AzureDevOpsRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; } +export class AzureDevOpsCli extends Context.Service< + AzureDevOpsCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, AzureDevOpsCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly remoteName?: string; + }) => Effect.Effect; + } +>()("t3/sourceControl/AzureDevOpsCli") {} + function normalizeChangeRequestId(reference: string): string { const trimmed = reference.trim().replace(/^#/, ""); const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); @@ -224,25 +317,27 @@ function parseRepositorySpecifier(repository: string): { function decodeAzureDevOpsJson( raw: string, schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, -): Effect.Effect { + operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, + cwd: string, +): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( - (error) => - new AzureDevOpsCliError({ + (cause) => + new AzureDevOpsRepositoryDecodeError({ operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, + command: "az", + cwd, + outputLength: raw.length, + cause, }), ), ); } -export const make = Effect.fn("makeAzureDevOpsCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: AzureDevOpsCliShape["execute"] = (input) => + const execute: AzureDevOpsCli["Service"]["execute"] = (input) => process .run({ operation: "AzureDevOpsCli.execute", @@ -251,9 +346,21 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); + .pipe( + Effect.mapError((error) => + AzureDevOpsCommandFailedError.fromVcsError( + { + operation: "execute", + command: "az", + cwd: input.cwd, + argumentCount: input.args.length, + }, + error, + ), + ), + ); - const executeJson = (input: Parameters[0]) => + const executeJson = (input: Parameters[0]) => execute({ ...input, args: [...input.args, "--only-show-errors", "--output", "json"], @@ -282,15 +389,15 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => - AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), - ).pipe( + : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestListDecodeError({ operation: "listPullRequests", - detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + command: "az", + cwd: input.cwd, + outputLength: raw.length, cause: decoded.failure, }), ); @@ -316,13 +423,15 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestDecodeError({ operation: "getPullRequest", - detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + command: "az", + cwd: input.cwd, + outputLength: raw.length, cause: decoded.failure, }), ); @@ -344,7 +453,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { raw, RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", - "Azure DevOps CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -369,12 +478,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "createRepository", - "Azure DevOps CLI returned invalid repository JSON.", - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), ), Effect.map(normalizeRepositoryCloneUrls), ); @@ -406,12 +510,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "getDefaultBranch", - "Azure DevOps CLI returned invalid repository JSON.", - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), ), @@ -434,4 +533,4 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }); }); -export const layer = Layer.effect(AzureDevOpsCli, make()); +export const layer = Layer.effect(AzureDevOpsCli, make); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 4ba3777159b..21db25e7991 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -6,8 +6,8 @@ import * as Option from "effect/Option"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; -function makeProvider(azure: Partial) { - return AzureDevOpsSourceControlProvider.make().pipe( +function makeProvider(azure: Partial) { + return AzureDevOpsSourceControlProvider.make.pipe( Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), ); } @@ -46,10 +46,51 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); +it.effect("adds change-request context while retaining Azure CLI causes", () => + Effect.gen(function* () { + const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ + operation: "execute", + command: "az", + cwd: "/repo", + argumentCount: 2, + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + checkoutPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: "az", + cwd: "/repo", + reference: "#42", + detail: "Azure DevOps CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("creates Azure DevOps PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8d8e081cb89..bf2ac982927 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -4,42 +4,34 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { + combinedAuthOutput, + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; -function providerError( - operation: string, - cause: AzureDevOpsCli.AzureDevOpsCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "azure-devops", - operation, - detail: cause.detail, - cause, - }); -} - -function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { +function parseAzureAuth(input: SourceControlAuthProbeInput) { const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); if (input.exitCode !== 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", detail: - SourceControlProviderDiscovery.firstSafeAuthLine( - SourceControlProviderDiscovery.combinedAuthOutput(input), - ) ?? "Run `az login` to authenticate Azure CLI.", + firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", }); } if (account !== undefined && account.length > 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "authenticated", account, host: "dev.azure.com", }); } - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unknown", host: "dev.azure.com", detail: "Azure CLI account status could not be parsed.", @@ -56,7 +48,7 @@ export const discovery = { parseAuth: parseAzureAuth, installHint: "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", -} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; +} satisfies SourceControlCliDiscoverySpec; function toChangeRequest(summary: { readonly number: number; @@ -80,7 +72,7 @@ function toChangeRequest(summary: { }; } -export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; return SourceControlProvider.SourceControlProvider.of({ @@ -97,13 +89,39 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); }, getChangeRequest: (input) => azure.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -117,20 +135,71 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - azure - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + azure.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - azure - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + azure.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - azure - .getDefaultBranch({ cwd: input.cwd }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + azure.getDefaultBranch({ cwd: input.cwd }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => azure .checkoutPullRequest({ @@ -138,8 +207,23 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* reference: input.reference, ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), }) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index e93362b8423..5a9759ace0b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -6,8 +6,14 @@ 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 { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; - +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -53,41 +59,46 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; - readonly git?: Partial; + readonly requestFailure?: ( + request: HttpClientRequest.HttpClientRequest, + ) => HttpClientError.HttpClientError; + readonly git?: Partial; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => - Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + input.requestFailure + ? Effect.fail(input.requestFailure(request)) + : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); const gitMock = { - readConfigValue: vi.fn(() => + readConfigValue: vi.fn(() => Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), ), - resolvePrimaryRemoteName: vi.fn( - () => Effect.succeed("origin"), - ), - ensureRemote: vi.fn(() => + resolvePrimaryRemoteName: vi.fn< + GitVcsDriver.GitVcsDriver["Service"]["resolvePrimaryRemoteName"] + >(() => Effect.succeed("origin")), + ensureRemote: vi.fn(() => Effect.succeed("octocat"), ), - fetchRemoteBranch: vi.fn( + fetchRemoteBranch: vi.fn( () => Effect.void, ), - fetchRemoteTrackingBranch: vi.fn( + fetchRemoteTrackingBranch: vi.fn< + GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] + >(() => Effect.void), + setBranchUpstream: vi.fn( () => Effect.void, ), - setBranchUpstream: vi.fn( - () => Effect.void, - ), - switchRef: vi.fn((request) => + switchRef: vi.fn((request) => Effect.succeed({ refName: request.refName }), ), - listLocalBranchNames: vi.fn(() => + listLocalBranchNames: vi.fn(() => Effect.succeed([]), ), }; const git = { ...gitMock, ...input.git, - } satisfies Partial; + } satisfies Partial; const driver = { listRemotes: () => @@ -106,7 +117,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; const layer = BitbucketApi.layer.pipe( Layer.provide( @@ -130,7 +141,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriver.VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], }), }), ), @@ -497,6 +508,97 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); +it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { + const transportCause = new Error("socket reset by peer"); + let requestFailure: HttpClientError.HttpClientError | undefined; + const { layer } = makeLayer({ + response: () => Response.json({}), + requestFailure: (request) => { + requestFailure = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: transportCause, + }), + }); + return requestFailure; + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* Effect.flip( + bitbucket.getPullRequest({ + cwd: "/repo", + reference: "42", + }), + ); + + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", + ); + assert.strictEqual(error.cause, requestFailure); + assert.strictEqual(requestFailure?.cause, transportCause); + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { + const responseBody = '{"error":{"message":"credential=secret-value"}}'; + const { layer } = makeLayer({ + response: () => new Response(responseBody, { status: 403 }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .checkoutPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 403); + assert.strictEqual(error.responseBodyLength, responseBody.length); + assert.notProperty(error, "responseBody"); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.", + ); + assert.notInclude(error.message, "secret-value"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { + const cause = new Error("response stream failed"); + const { layer } = makeLayer({ + response: () => + new Response( + new ReadableStream({ + start: (controller) => controller.error(cause), + }), + { status: 502 }, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .getPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 502); + assert.instanceOf(error.cause, HttpClientError.HttpClientError); + assert.strictEqual(error.cause.cause, cause); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", + ); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => @@ -549,6 +651,51 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket }).pipe(Effect.provide(layer)); }); +it.effect("preserves Git checkout failures without deriving the domain message from them", () => { + const gitCause = new GitCommandError({ + operation: "fetchRemoteBranch", + command: "git fetch origin feature/source-control", + cwd: "/repo", + detail: "remote rejected the request", + }); + const { layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, + }), + git: { + fetchRemoteBranch: () => Effect.fail(gitCause), + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* Effect.flip( + bitbucket.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + force: true, + }), + ); + + assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.reference, "42"); + assert.strictEqual( + error.message, + "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", + ); + assert.strictEqual(error.cause, gitCause); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out fork pull requests through an ensured fork remote", () => { const { git, layer } = makeLayer({ response: (request) => { diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 632778eca24..f7d7f6671a4 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, @@ -15,7 +16,12 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { sanitizeBranchFragment } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import { + BitbucketPullRequestListSchema, + BitbucketPullRequestSchema, + normalizeBitbucketPullRequestRecord, + type NormalizedBitbucketPullRequestRecord, +} from "./bitbucketPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -31,20 +37,156 @@ const BitbucketApiEnvConfig = Config.all({ apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), }); -export class BitbucketApiError extends Schema.TaggedErrorClass()( - "BitbucketApiError", +const BitbucketApiOperation = Schema.Literals([ + "resolveRepository", + "getRepository", + "getBranchingModel", + "getPullRequest", + "listPullRequests", + "createRepository", + "createPullRequest", + "probeAuth", + "checkoutPullRequest", +]); +type BitbucketApiOperation = typeof BitbucketApiOperation.Type; + +export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryLocatorError", + { + repository: Schema.String, + }, +) { + override get message(): string { + return "Bitbucket API failed in createRepository: Bitbucket repositories must be specified as workspace/repository."; + } +} + +export class BitbucketRequestError extends Schema.TaggedErrorClass()( + "BitbucketRequestError", + { + operation: BitbucketApiOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Failed to send the Bitbucket request.`; + } +} + +export class BitbucketResponseError extends Schema.TaggedErrorClass()( + "BitbucketResponseError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + responseBodyLength: NonNegativeInt, + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; + } +} + +export class BitbucketResponseBodyReadError extends Schema.TaggedErrorClass()( + "BitbucketResponseBodyReadError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; + } +} + +export class BitbucketResponseDecodeError extends Schema.TaggedErrorClass()( + "BitbucketResponseDecodeError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned invalid JSON for the requested resource.`; + } +} + +export class BitbucketRepositoryVcsResolveError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryVcsResolveError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: Failed to resolve VCS repository for ${this.cwd}.`; + } +} + +export class BitbucketRepositoryRemotesListError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryRemotesListError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: Failed to list remotes for ${this.cwd}.`; + } +} + +export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryRemoteNotFoundError", + { + cwd: Schema.String, + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: No Bitbucket repository remote was detected for ${this.cwd}.`; + } +} + +export class BitbucketPullRequestBodyReadError extends Schema.TaggedErrorClass()( + "BitbucketPullRequestBodyReadError", { - operation: Schema.String, - detail: Schema.String, - status: Schema.optional(Schema.Number), - cause: Schema.optional(Schema.Defect()), + cwd: Schema.String, + bodyFile: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + return `Bitbucket API failed in createPullRequest: Failed to read pull request body file ${this.bodyFile}.`; } } -const isBitbucketApiErrorValue = Schema.is(BitbucketApiError); + +export class BitbucketCheckoutError extends Schema.TaggedErrorClass()( + "BitbucketCheckoutError", + { + cwd: Schema.String, + reference: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request."; + } +} + +export const BitbucketApiError = Schema.Union([ + BitbucketRepositoryLocatorError, + BitbucketRequestError, + BitbucketResponseError, + BitbucketResponseBodyReadError, + BitbucketResponseDecodeError, + BitbucketRepositoryVcsResolveError, + BitbucketRepositoryRemotesListError, + BitbucketRepositoryRemoteNotFoundError, + BitbucketPullRequestBodyReadError, + BitbucketCheckoutError, +]); +export type BitbucketApiError = typeof BitbucketApiError.Type; +export const isBitbucketApiError = Schema.is(BitbucketApiError); const RawBitbucketRepositorySchema = Schema.Struct({ full_name: TrimmedNonEmptyString, @@ -100,62 +242,55 @@ export interface BitbucketRepositoryLocator { readonly repoSlug: string; } -export interface BitbucketApiShape { - readonly probeAuth: Effect.Effect; - readonly listPullRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect< - ReadonlyArray, - BitbucketApiError - >; - readonly getPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect< - BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, - BitbucketApiError - >; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly createPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -export class BitbucketApi extends Context.Service()( - "t3/sourceControl/BitbucketApi", -) {} +export class BitbucketApi extends Context.Service< + BitbucketApi, + { + readonly probeAuth: Effect.Effect; + readonly listPullRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, BitbucketApiError>; + readonly getPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly createPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/BitbucketApi") {} function nonEmpty(value: string | undefined): Option.Option { const trimmed = value?.trim(); @@ -211,16 +346,14 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator } function requireRepositoryLocator( - operation: string, repository: string, ): Effect.Effect { const locator = parseBitbucketRepositorySlug(repository); return locator ? Effect.succeed(locator) : Effect.fail( - new BitbucketApiError({ - operation, - detail: "Bitbucket repositories must be specified as workspace/repository.", + new BitbucketRepositoryLocatorError({ + repository, }), ); } @@ -299,9 +432,7 @@ function checkoutBranchName(input: { } function repositoryNameWithOwner( - repository: Schema.Schema.Type< - typeof BitbucketPullRequests.BitbucketPullRequestSchema - >["source"]["repository"], + repository: Schema.Schema.Type["source"]["repository"], ): string | null { const fullName = repository?.full_name?.trim() ?? ""; return fullName.length > 0 ? fullName : null; @@ -342,40 +473,32 @@ function authFromConfig( }; } -function requestError(operation: string, cause: unknown): BitbucketApiError { - return new BitbucketApiError({ - operation, - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }); -} - -function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { - return isBitbucketApiErrorValue(cause); -} - function responseError( - operation: string, + operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { return response.text.pipe( - Effect.orElseSucceed(() => ""), + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation, + status: response.status, + cause, + }), + ), Effect.flatMap((body) => Effect.fail( - new BitbucketApiError({ + new BitbucketResponseError({ operation, status: response.status, - detail: - body.trim().length > 0 - ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` - : `Bitbucket returned HTTP ${response.status}.`, + responseBodyLength: body.length, }), ), ), ); } -export const make = Effect.fn("makeBitbucketApi")(function* () { +export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; @@ -395,7 +518,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }; const decodeResponse = ( - operation: string, + operation: BitbucketApiOperation, schema: S, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect => @@ -404,9 +527,9 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { HttpClientResponse.schemaBodyJson(schema)(success).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ + new BitbucketResponseDecodeError({ operation, - detail: "Bitbucket returned invalid JSON for the requested resource.", + status: success.status, cause, }), ), @@ -415,12 +538,18 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { })(response); const executeJson = ( - operation: string, + operation: BitbucketApiOperation, request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( - Effect.mapError((cause) => requestError(operation, cause)), + Effect.mapError( + (cause) => + new BitbucketRequestError({ + operation, + cause, + }), + ), Effect.flatMap((response) => decodeResponse(operation, schema, response)), ); @@ -442,9 +571,8 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to resolve VCS repository for ${input.cwd}.`, + new BitbucketRepositoryVcsResolveError({ + cwd: input.cwd, cause, }), ), @@ -452,9 +580,8 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to list remotes for ${input.cwd}.`, + new BitbucketRepositoryRemotesListError({ + cwd: input.cwd, cause, }), ), @@ -466,9 +593,8 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { if (parsed) return parsed; } - return yield* new BitbucketApiError({ - operation: "resolveRepository", - detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, + return yield* new BitbucketRepositoryRemoteNotFoundError({ + cwd: input.cwd, }); }); @@ -511,7 +637,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, ), ), - BitbucketPullRequests.BitbucketPullRequestSchema, + BitbucketPullRequestSchema, ); const getRawPullRequest = (input: { @@ -599,21 +725,17 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { ), { urlParams: query }, ), - BitbucketPullRequests.BitbucketPullRequestListSchema, + BitbucketPullRequestListSchema, ); }), - Effect.map((list) => - list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), - ), + Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), ), getPullRequest: (input) => - getRawPullRequest(input).pipe( - Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), - ), + getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createRepository: (input) => - requireRepositoryLocator("createRepository", input.repository).pipe( + requireRepositoryLocator(input.repository).pipe( Effect.flatMap((repository) => executeJson( "createRepository", @@ -638,9 +760,9 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { const description = yield* fileSystem.readFileString(input.bodyFile).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "createPullRequest", - detail: `Failed to read pull request body file ${input.bodyFile}.`, + new BitbucketPullRequestBodyReadError({ + cwd: input.cwd, + bodyFile: input.bodyFile, cause, }), ), @@ -675,7 +797,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, ), ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), - BitbucketPullRequests.BitbucketPullRequestSchema, + BitbucketPullRequestSchema, ); }), getDefaultBranch: (input) => @@ -756,9 +878,9 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { Effect.mapError((cause) => isBitbucketApiError(cause) ? cause - : new BitbucketApiError({ - operation: "checkoutPullRequest", - detail: cause instanceof Error ? cause.message : String(cause), + : new BitbucketCheckoutError({ + cwd: input.cwd, + reference: input.reference, cause, }), ), @@ -766,4 +888,4 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }); }); -export const layer = Layer.effect(BitbucketApi, make()); +export const layer = Layer.effect(BitbucketApi, make); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 07a3d386a35..eeb4c8fbdd2 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -6,8 +6,8 @@ import * as Option from "effect/Option"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -function makeProvider(bitbucket: Partial) { - return BitbucketSourceControlProvider.make().pipe( +function makeProvider(bitbucket: Partial) { + return BitbucketSourceControlProvider.make.pipe( Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), ); } @@ -51,9 +51,48 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( }), ); +it.effect("adds repository context while retaining Bitbucket API causes", () => + Effect.gen(function* () { + const upstreamCause = new Error("raw upstream failure"); + const cause = new BitbucketApi.BitbucketRequestError({ + operation: "getRepository", + cause: upstreamCause, + }); + const provider = yield* makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(cause), + }); + + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + command: undefined, + cwd: "/repo", + repository: "owner/repo", + detail: "Failed to get repository clone URLs.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(upstreamCause.message), false); + }), +); + it.effect("lists Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ listPullRequests: (input) => { listInput = input; @@ -79,8 +118,9 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () => it.effect("creates Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index f3fd502f7fb..974fbb94a39 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -4,25 +4,11 @@ import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; -import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; -function providerError( - operation: string, - cause: BitbucketApi.BitbucketApiError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "bitbucket", - operation, - detail: cause.detail, - cause, - }); -} - -function toChangeRequest( - summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, -): ChangeRequest { +function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { provider: "bitbucket", number: summary.number, @@ -44,7 +30,7 @@ function toChangeRequest( }; } -export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; return SourceControlProvider.SourceControlProvider.of({ @@ -62,13 +48,37 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "listChangeRequests", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: "Failed to list change requests.", + cause: error, + }), + ), ); }, getChangeRequest: (input) => bitbucket.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to get change request.", + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -83,23 +93,72 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "createChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: "Failed to create change request.", + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - bitbucket - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + bitbucket.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Failed to get repository clone URLs.", + cause: error, + }), + ), + ), createRepository: (input) => - bitbucket - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + bitbucket.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "createRepository", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Failed to create repository.", + cause: error, + }), + ), + ), getDefaultBranch: (input) => bitbucket .getDefaultBranch({ cwd: input.cwd, ...(input.context ? { context: input.context } : {}), }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getDefaultBranch", + cwd: input.cwd, + detail: "Failed to get default branch.", + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => bitbucket .checkoutPullRequest({ @@ -108,13 +167,27 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () reference: input.reference, ...(input.force !== undefined ? { force: input.force } : {}), }) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to check out change request.", + cause: error, + }), + ), + ), }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); -export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () { +export const makeDiscovery = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; return { @@ -124,5 +197,5 @@ export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscov installHint: "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", probeAuth: bitbucket.probeAuth, - } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; + } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index fb765b352c2..5df4862b409 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,8 +1,9 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -15,7 +16,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( Layer.provide( @@ -30,6 +31,27 @@ afterEach(() => { }); describe("GitHubCli.layer", () => { + it("does not classify a missing cwd as an unavailable gh executable", () => { + const context = { command: "gh", cwd: "/repo" } as const; + const missingCwd = new VcsProcessSpawnError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: context.cwd, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "access", + pathOrDescriptor: context.cwd, + }), + }); + + const commandFailure = GitHubCli.fromVcsError(context, missingCwd); + + assert.equal(commandFailure._tag, "GitHubCliCommandError"); + assert.strictEqual(commandFailure.cause, missingCwd); + assert.notProperty(commandFailure, "operation"); + }); + it.effect("parses pull request view output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -269,18 +291,16 @@ describe("GitHubCli.layer", () => { it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.fail( - new VcsProcessExitError({ - operation: "GitHubCli.execute", - command: "gh pr view", - cwd: "/repo", - exitCode: 1, - detail: - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - }), - ), - ); + const cause = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + failureKind: "not-found", + detail: + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); const gh = yield* GitHubCli.GitHubCli; const error = yield* gh @@ -291,6 +311,11 @@ describe("GitHubCli.layer", () => { .pipe(Effect.flip); assert.equal(error.message.includes("Pull request not found"), true); + assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); + assert.strictEqual(error.command, "gh"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index d6c858c28bd..bf3f27378b5 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,9 +1,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { TrimmedNonEmptyString, @@ -12,154 +12,249 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import { + decodeGitHubPullRequestJson, + decodeGitHubPullRequestListJson, +} from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { +const gitHubCliFailureFields = { + command: Schema.Literal("gh"), + cwd: Schema.String, + cause: Schema.Defect(), +} as const; + +export class GitHubCliUnavailableError extends Schema.TaggedErrorClass()( + "GitHubCliUnavailableError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI (`gh`) is required but not available on PATH."; + } + override get message(): string { - return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + return `GitHub CLI failed in execute: ${this.detail}`; } } -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state?: "open" | "closed" | "merged"; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} +export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitHubCliAuthenticationError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; + } -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } } -export interface GitHubCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; +export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubPullRequestNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; +export class GitHubCliCommandError extends Schema.TaggedErrorClass()( + "GitHubCliCommandError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI command failed."; + } - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; +const gitHubCliDecodeFields = { + command: Schema.Literal("gh"), + cwd: Schema.String, + cause: Schema.Defect(), +} as const; + +export class GitHubPullRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid PR list JSON."; + } - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; + override get message(): string { + return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`; + } +} - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; +export class GitHubChangeRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitHubChangeRequestListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid change request JSON."; + } - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; + override get message(): string { + return `GitHub CLI failed in listChangeRequests: ${this.detail}`; + } } -export class GitHubCli extends Context.Service()( - "t3/sourceControl/GitHubCli", -) {} - -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); +export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request JSON."; } - return String(error); + override get message(): string { + return `GitHub CLI failed in getPullRequest: ${this.detail}`; + } } -function normalizeGitHubCliError( - operation: "execute" | "stdout", - error: VcsError | unknown, -): GitHubCliError { - const text = errorText(error); - const lower = text.toLowerCase(); - - if (lower.includes("command not found: gh") || lower.includes("enoent")) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); +export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitHubRepositoryDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository JSON."; } - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); + override get message(): string { + return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`; } +} +export const GitHubCliError = Schema.Union([ + GitHubCliUnavailableError, + GitHubCliAuthenticationError, + GitHubPullRequestNotFoundError, + GitHubCliCommandError, + GitHubPullRequestListDecodeError, + GitHubChangeRequestListDecodeError, + GitHubPullRequestDecodeError, + GitHubRepositoryDecodeError, +]); +export type GitHubCliError = typeof GitHubCliError.Type; + +export const isGitHubCliError = Schema.is(GitHubCliError); + +export function fromVcsError( + context: { + readonly command: "gh"; + readonly cwd: string; + }, + error: VcsError, +): GitHubCliError { if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" && + error.cause.reason.module === "ChildProcess" && + error.cause.reason.method === "spawn" ) { - return new GitHubCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); + return new GitHubCliUnavailableError({ ...context, cause: error }); } - return new GitHubCliError({ - operation, - detail: text, - cause: error, - }); + if (error._tag === "VcsProcessExitError") { + if (error.failureKind === "authentication") { + return new GitHubCliAuthenticationError({ ...context, cause: error }); + } + if (error.failureKind === "not-found") { + return new GitHubPullRequestNotFoundError({ ...context, cause: error }); + } + } + + return new GitHubCliCommandError({ ...context, cause: error }); } +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export class GitHubCli extends Context.Service< + GitHubCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("t3/sourceControl/GitHubCli") {} + const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); +const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), +); function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, @@ -208,28 +303,10 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } -function decodeGitHubJson( - raw: string, - schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", - invalidDetail: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitHubCliError({ - operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, - }), - ), - ); -} - -export const make = Effect.fn("makeGitHubCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitHubCliShape["execute"] = (input) => + const execute: GitHubCli["Service"]["execute"] = (input) => process .run({ operation: "GitHubCli.execute", @@ -238,7 +315,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); + .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); return GitHubCli.of({ execute, @@ -262,13 +339,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubCliError({ - operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, + new GitHubPullRequestListDecodeError({ + command: "gh", + cwd: input.cwd, cause: decoded.failure, }), ); @@ -294,13 +371,13 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( + Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubCliError({ - operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, + new GitHubPullRequestDecodeError({ + command: "gh", + cwd: input.cwd, cause: decoded.failure, }), ); @@ -320,11 +397,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitHubJson( - raw, - RawGitHubRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitHub CLI returned invalid repository JSON.", + decodeRawGitHubRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositoryDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -372,4 +453,4 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make()); +export const layer = Layer.effect(GitHubCli, make); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 32fd1a91ce3..9e8a6829566 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -24,8 +24,8 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider(github: Partial) { - return GitHubSourceControlProvider.make().pipe( +function makeProvider(github: Partial) { + return GitHubSourceControlProvider.make.pipe( Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), ); } @@ -68,6 +68,47 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); +it.effect("adds safe request context while retaining GitHub CLI causes", () => + Effect.gen(function* () { + const cause = new GitHubCli.GitHubPullRequestNotFoundError({ + command: "gh", + cwd: "/repo", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + getPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "github", + operation: "getChangeRequest", + command: "gh", + cwd: "/repo", + reference: "https://github.com/pingdotgg/t3code/pull/42", + detail: "Pull request not found. Check the PR number or URL and try again.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("uses gh json listing for non-open change request state queries", () => Effect.gen(function* () { let executeArgs: ReadonlyArray = []; @@ -139,7 +180,8 @@ it.effect("treats empty non-open change request listing output as no results", ( it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 41329b97f75..b5d5d3a55f8 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; import { SourceControlProviderError, type ChangeRequest, @@ -11,22 +10,15 @@ import { import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; -import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; -const isSourceControlProviderError = Schema.is(SourceControlProviderError); - -function providerError( - operation: string, - cause: GitHubCli.GitHubCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "github", - operation, - detail: cause.detail, - cause, - }); -} +import { + combinedAuthOutput, + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { return { @@ -50,14 +42,14 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } -function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { - const output = SourceControlProviderDiscovery.combinedAuthOutput(input); +function parseGitHubAuth(input: SourceControlAuthProbeInput) { + const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; if (authenticatedAccount) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "authenticated", account: authenticatedAccount.account, host, @@ -66,7 +58,7 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; if (authStatus.parsed) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", host: failedAccount?.host, detail: @@ -76,21 +68,17 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth } if (input.exitCode !== 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "Run `gh auth login` to authenticate GitHub CLI.", + detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", }); } - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unknown", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "GitHub CLI auth status could not be parsed.", + detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", }); } @@ -104,12 +92,12 @@ export const discovery = { parseAuth: parseGitHubAuth, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", -} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; +} satisfies SourceControlCliDiscoverySpec; -export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; - const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = + const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { if (input.state === "open") { return github @@ -120,7 +108,20 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); } @@ -147,7 +148,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { if (raw.length === 0) { return Effect.succeed([]); } - return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( @@ -157,20 +158,28 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { })), ) : Effect.fail( - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - detail: "GitHub CLI returned invalid change request JSON.", + new GitHubCli.GitHubChangeRequestListDecodeError({ + command: "gh", + cwd: input.cwd, cause: decoded.failure, }), ), ), ); }), - Effect.mapError((error) => - isSourceControlProviderError(error) - ? error - : providerError("listChangeRequests", error), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), ), ); }; @@ -181,7 +190,20 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { getChangeRequest: (input) => github.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => github @@ -192,24 +214,88 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ), getRepositoryCloneUrls: (input) => - github - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + github.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - github - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + github.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - github - .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + github.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => - github - .checkoutPullRequest(input) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + github.checkoutPullRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index c075027151a..87621e5c8bc 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -8,7 +8,7 @@ import { VcsProcessExitError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitLabCli from "./GitLabCli.ts"; -const mockedRun = vi.fn(); +const mockedRun = vi.fn(); const layer = it.layer( GitLabCli.layer.pipe( Layer.provide( @@ -313,17 +313,15 @@ layer("GitLabCli.layer", (it) => { it.effect("surfaces a friendly error when the merge request is not found", () => Effect.gen(function* () { - mockedRun.mockReturnValueOnce( - Effect.fail( - new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab mr view 4888", - cwd: "/repo", - exitCode: 1, - detail: "GET 404 merge request not found", - }), - ), - ); + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 merge request not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); const error = yield* Effect.gen(function* () { const glab = yield* GitLabCli.GitLabCli; @@ -333,7 +331,37 @@ layer("GitLabCli.layer", (it) => { }); }).pipe(Effect.flip); - assert.equal(error.message.includes("Merge request not found"), true); + assert.equal(error.message.includes("Merge request 4888 was not found"), true); + assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); + assert.strictEqual(error.command, "glab"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); + }), + ); + + it.effect("keeps non-merge-request not-found failures generic", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 project not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "missing/project", + }); + }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitLabCliCommandError"); + assert.strictEqual(error.cause, cause); }), ); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index bd430d9d01a..a2926afd0ef 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -1,30 +1,228 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import type * as DateTime from "effect/DateTime"; -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; +import { + decodeGitLabMergeRequestJson, + decodeGitLabMergeRequestListJson, +} from "./gitLabMergeRequests.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { +const gitLabCliExecutionErrorContext = { + operation: Schema.Literal("execute"), + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +const gitLabCliDecodeErrorContext = { + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +export class GitLabCliUnavailableError extends Schema.TaggedErrorClass()( + "GitLabCliUnavailableError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI (`glab`) is required but not available on PATH."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitLabCliAuthenticationError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestNotFoundError", + { + ...gitLabCliExecutionErrorContext, + reference: Schema.String, + }, +) { + get detail(): string { + return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + readonly reference: string; + }, + error: VcsError, + ): GitLabCliError { + if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { + return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); + } + + return GitLabCliCommandError.fromVcsError( + { + operation: context.operation, + command: context.command, + cwd: context.cwd, + }, + error, + ); + } +} + +export class GitLabCliCommandError extends Schema.TaggedErrorClass()( + "GitLabCliCommandError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI command failed."; + } + override get message(): string { return `GitLab CLI failed in ${this.operation}: ${this.detail}`; } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + }, + error: VcsError, + ): GitLabCliError { + return Match.valueTags(error, { + VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), + VcsProcessExitError: (cause) => { + switch (cause.failureKind) { + case "authentication": + return new GitLabCliAuthenticationError({ ...context, cause }); + case "not-found": + case "command-failed": + case undefined: + return new GitLabCliCommandError({ ...context, cause }); + } + }, + VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), + }); + } } +export class GitLabMergeRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestListDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("listMergeRequests"), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid MR list JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestDecodeError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("getMergeRequest"), + reference: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid merge request JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitLabRepositoryDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), + repository: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid repository JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabNamespaceDecodeError extends Schema.TaggedErrorClass()( + "GitLabNamespaceDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("createRepository"), + namespacePath: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid namespace JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export const GitLabCliError = Schema.Union([ + GitLabCliUnavailableError, + GitLabCliAuthenticationError, + GitLabMergeRequestNotFoundError, + GitLabCliCommandError, + GitLabMergeRequestListDecodeError, + GitLabMergeRequestDecodeError, + GitLabRepositoryDecodeError, + GitLabNamespaceDecodeError, +]); +export type GitLabCliError = typeof GitLabCliError.Type; +export const isGitLabCliError = Schema.is(GitLabCliError); + export interface GitLabMergeRequestSummary { readonly number: number; readonly title: string; @@ -44,120 +242,60 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } -export interface GitLabCliShape { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - readonly listMergeRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, GitLabCliError>; - - readonly getMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createMergeRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -export class GitLabCli extends Context.Service()( - "t3/sourceControl/GitLabCli", -) {} - -function isVcsProcessSpawnError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "VcsProcessSpawnError" - ); -} - -function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { - if (error instanceof Error) { - if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) { - return new GitLabCliError({ - operation, - detail: "GitLab CLI (`glab`) is required but not available on PATH.", - cause: error, - }); - } - - const lower = error.message.toLowerCase(); - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("glab auth login") || - lower.includes("token") - ) { - return new GitLabCliError({ - operation, - detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("merge request not found") || - lower.includes("not found") || - lower.includes("404") - ) { - return new GitLabCliError({ - operation, - detail: "Merge request not found. Check the MR number or URL and try again.", - cause: error, - }); - } - - return new GitLabCliError({ - operation, - detail: `GitLab CLI command failed: ${error.message}`, - cause: error, - }); +export class GitLabCli extends Context.Service< + GitLabCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitLabCliError>; + + readonly getMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createMergeRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; } - - return new GitLabCliError({ - operation, - detail: "GitLab CLI command failed.", - cause: error, - }); -} +>()("t3/sourceControl/GitLabCli") {} const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ path_with_namespace: TrimmedNonEmptyString, @@ -174,6 +312,14 @@ const RawGitLabNamespaceSchema = Schema.Struct({ id: Schema.Number, }); +const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), +); +const decodeGitLabDefaultBranch = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabDefaultBranchSchema), +); +const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitLabRepositoryCloneUrls { @@ -184,24 +330,6 @@ function normalizeRepositoryCloneUrls( }; } -function decodeGitLabJson( - raw: string, - schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitLabCliError({ - operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, - }), - ), - ); -} - function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { switch (state) { case "open": @@ -259,10 +387,13 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } -export const make = Effect.fn("makeGitLabCli")(function* () { +export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitLabCliShape["execute"] = (input) => + const run = ( + input: Parameters[0], + mapError: (error: VcsError) => GitLabCliError, + ) => process .run({ operation: "GitLabCli.execute", @@ -271,7 +402,32 @@ export const make = Effect.fn("makeGitLabCli")(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); + .pipe(Effect.mapError(mapError)); + + const execute: GitLabCli["Service"]["execute"] = (input) => + run(input, (error) => + GitLabCliCommandError.fromVcsError( + { operation: "execute", command: "glab", cwd: input.cwd }, + error, + ), + ); + + const executeMergeRequest = (input: { + readonly cwd: string; + readonly reference: string; + readonly args: ReadonlyArray; + }) => + run(input, (error) => + GitLabMergeRequestNotFoundError.fromVcsError( + { + operation: "execute", + command: "glab", + cwd: input.cwd, + reference: input.reference, + }, + error, + ), + ); return GitLabCli.of({ execute, @@ -294,13 +450,14 @@ export const make = Effect.fn("makeGitLabCli")(function* () { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( + : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestListDecodeError({ operation: "listMergeRequests", - detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + command: "glab", + cwd: input.cwd, cause: decoded.failure, }), ); @@ -312,19 +469,22 @@ export const make = Effect.fn("makeGitLabCli")(function* () { ), ), getMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "view", input.reference, "--output", "json"], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( + Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestDecodeError({ operation: "getMergeRequest", - detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + command: "glab", + cwd: input.cwd, + reference: input.reference, cause: decoded.failure, }), ); @@ -342,11 +502,17 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitLab CLI returned invalid repository JSON.", + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getRepositoryCloneUrls", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -360,11 +526,17 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabNamespaceSchema, - "createRepository", - "GitLab CLI returned invalid namespace JSON.", + decodeGitLabNamespace(raw).pipe( + Effect.mapError( + (cause) => + new GitLabNamespaceDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + namespacePath, + cause, + }), + ), ), ), Effect.map((namespace) => namespace.id), @@ -394,11 +566,17 @@ export const make = Effect.fn("makeGitLabCli")(function* () { ), Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "createRepository", - "GitLab CLI returned invalid repository JSON.", + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -432,21 +610,27 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabDefaultBranchSchema, - "getDefaultBranch", - "GitLab CLI returned invalid repository JSON.", + decodeGitLabDefaultBranch(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getDefaultBranch", + command: "glab", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map((value) => value.default_branch ?? null), ), checkoutMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "checkout", input.reference], }).pipe(Effect.asVoid), }); }); -export const layer = Layer.effect(GitLabCli, make()); +export const layer = Layer.effect(GitLabCli, make); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 842cf4a17cf..0d06e066521 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -8,8 +8,8 @@ import * as GitLabCli from "./GitLabCli.ts"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; -function makeProvider(gitlab: Partial) { - return GitLabSourceControlProvider.make().pipe( +function makeProvider(gitlab: Partial) { + return GitLabSourceControlProvider.make.pipe( Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), ); } @@ -52,9 +52,52 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = }), ); +it.effect("adds repository context while retaining GitLab CLI causes", () => + Effect.gen(function* () { + const cause = new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: "/repo", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + createRepository: () => Effect.fail(cause), + }); + + const error = yield* provider + .createRepository({ + cwd: "/repo", + repository: "owner/repo", + visibility: "private", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "gitlab", + operation: "createRepository", + command: "glab", + cwd: "/repo", + repository: "owner/repo", + detail: "GitLab CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("lists GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let listInput: Parameters[0] | null = null; + let listInput: Parameters[0] | null = null; const provider = yield* makeProvider({ listMergeRequests: (input) => { listInput = input; @@ -80,7 +123,8 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createMergeRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 77f41600e0f..2cba12f1b3f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -5,21 +5,18 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac import * as GitLabCli from "./GitLabCli.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { + combinedAuthOutput, + firstSafeAuthLine, + matchFirst, + parseCliHost, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, + type SourceControlUnknownRemoteRefinementInput, +} from "./SourceControlProviderDiscovery.ts"; import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; -function providerError( - operation: string, - cause: GitLabCli.GitLabCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "gitlab", - operation, - detail: cause.detail, - cause, - }); -} - function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { return { provider: "gitlab", @@ -42,48 +39,42 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe }; } -function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { - const output = SourceControlProviderDiscovery.combinedAuthOutput(input); +function parseGitLabAuth(input: SourceControlAuthProbeInput) { + const output = combinedAuthOutput(input); const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output)); const account = authenticatedHost?.account ?? - SourceControlProviderDiscovery.matchFirst(output, [ + matchFirst(output, [ /Logged in to .* as\s+([^\s(]+)/iu, /Logged in to .* account\s+([^\s(]+)/iu, /account:\s*([^\s(]+)/iu, ]); - const host = authenticatedHost?.host ?? SourceControlProviderDiscovery.parseCliHost(output); + const host = authenticatedHost?.host ?? parseCliHost(output); if (account) { - return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); + return providerAuth({ status: "authenticated", account, host }); } if (input.exitCode !== 0) { - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unauthenticated", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "Run `glab auth login` to authenticate GitLab CLI.", + detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", }); } - return SourceControlProviderDiscovery.providerAuth({ + return providerAuth({ status: "unknown", host, - detail: - SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? - "GitLab CLI auth status could not be parsed.", + detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", }); } -function refineUnknownGitLabRemote( - input: SourceControlProviderDiscovery.SourceControlUnknownRemoteRefinementInput, -) { +function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) { const host = input.context.provider.name.toLowerCase(); - const authenticated = parseGitLabAuthStatusHosts( - SourceControlProviderDiscovery.combinedAuthOutput(input.auth), - ).some((entry) => entry.account !== null && entry.host === host); + const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some( + (entry) => entry.account !== null && entry.host === host, + ); if (!authenticated) { return null; @@ -107,9 +98,9 @@ export const discovery = { refineUnknownRemote: refineUnknownGitLabRemote, installHint: "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", -} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; +} satisfies SourceControlCliDiscoverySpec; -export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { +export const make = Effect.gen(function* () { const gitlab = yield* GitLabCli.GitLabCli; return SourceControlProvider.SourceControlProvider.of({ @@ -126,13 +117,39 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); }, getChangeRequest: (input) => gitlab.getMergeRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -146,25 +163,89 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - gitlab - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + gitlab.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - gitlab - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + gitlab.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - gitlab - .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + gitlab.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => - gitlab - .checkoutMergeRequest(input) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + gitlab.checkoutMergeRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index f65710c4c9c..9e4702af04c 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessSpawnError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -17,15 +17,15 @@ import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const sourceControlProviderRegistryTestLayer = (input: { - readonly bitbucket: Partial; - readonly process: Partial; + readonly bitbucket: Partial; + readonly process: Partial; }) => SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( - Layer.provide(NodeServices.layer), - ), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-test-", + }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli.GitHubCli)({}), @@ -88,10 +88,12 @@ it.effect("reports implemented tools separately from locally available executabl }), ); }, - } satisfies Partial; + } satisfies Partial; const testLayer = SourceControlDiscovery.layer.pipe( Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-discovery-", + }), ), Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), Layer.provide( @@ -215,10 +217,12 @@ Logged in to gitlab.com as gitlab-user }), ); }, - } satisfies Partial; + } satisfies Partial; const testLayer = SourceControlDiscovery.layer.pipe( Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-auth-discovery-", + }), ), Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), Layer.provide( diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index eab46d23560..660f32283e0 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; interface DiscoveryProbe { @@ -57,91 +57,86 @@ const VCS_PROBES: ReadonlyArray = [ }, ]; -export interface SourceControlDiscoveryShape { - readonly discover: Effect.Effect; -} - export class SourceControlDiscovery extends Context.Service< SourceControlDiscovery, - SourceControlDiscoveryShape + { + readonly discover: Effect.Effect; + } >()("t3/sourceControl/SourceControlDiscovery") {} -export const layer = Layer.effect( - SourceControlDiscovery, - Effect.gen(function* () { - const config = yield* ServerConfig; - const process = yield* VcsProcess.VcsProcess; - const sourceControlProviders = - yield* SourceControlProviderRegistry.SourceControlProviderRegistry; +export const make = Effect.gen(function* () { + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; - const probe = ( - input: DiscoveryProbe & { readonly kind: Kind }, - ): Effect.Effect> => { - const executable = input.executable; - const versionArgs = input.versionArgs; + const probe = ( + input: DiscoveryProbe & { readonly kind: Kind }, + ): Effect.Effect> => { + const executable = input.executable; + const versionArgs = input.versionArgs; - if (!executable || !versionArgs) { - return Effect.succeed({ - kind: input.kind, - label: input.label, - implemented: input.implemented, - status: "missing" as const, - version: Option.none(), - installHint: input.installHint, - detail: Option.some(input.installHint), - } satisfies DiscoveryProbeResult); - } + if (!executable || !versionArgs) { + return Effect.succeed({ + kind: input.kind, + label: input.label, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: Option.some(input.installHint), + } satisfies DiscoveryProbeResult); + } - return process - .run({ - operation: "source-control.discovery.probe", - command: executable, - args: versionArgs, - cwd: config.cwd, - timeoutMs: 5_000, - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map( - (result) => - ({ - kind: input.kind, - label: input.label, - executable, - implemented: input.implemented, - status: "available" as const, - version: Option.orElse( - SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), - () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), - ), - installHint: input.installHint, - detail: Option.none(), - }) satisfies DiscoveryProbeResult, - ), - Effect.catch((cause) => - Effect.succeed({ + return process + .run({ + operation: "source-control.discovery.probe", + command: executable, + args: versionArgs, + cwd: config.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ kind: input.kind, label: input.label, executable, implemented: input.implemented, - status: "missing" as const, - version: Option.none(), + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), installHint: input.installHint, - detail: SourceControlProviderDiscovery.detailFromCause(cause), - } satisfies DiscoveryProbeResult), - ), - ); - }; - - return SourceControlDiscovery.of({ - discover: Effect.all({ - versionControlSystems: Effect.all( - VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, - { concurrency: "unbounded" }, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.kind, + label: input.label, + executable, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), ), - sourceControlProviders: sourceControlProviders.discover, - }), - }); - }), -); + ); + }; + + return SourceControlDiscovery.of({ + discover: Effect.all({ + versionControlSystems: Effect.all( + VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, + { concurrency: "unbounded" }, + ), + sourceControlProviders: sourceControlProviders.discover, + }), + }); +}); + +export const layer = Layer.effect(SourceControlDiscovery, make); diff --git a/apps/server/src/sourceControl/SourceControlProvider.test.ts b/apps/server/src/sourceControl/SourceControlProvider.test.ts new file mode 100644 index 00000000000..7e532488279 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProvider.test.ts @@ -0,0 +1,19 @@ +import { assert, it } from "@effect/vitest"; + +import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts"; + +it("removes URL credentials, query parameters, and fragments from error transport values", () => { + assert.strictEqual( + transportSafeSourceControlErrorValue( + "https://user:secret@example.test/org/repo/pull/42?token=secret#discussion", + ), + "https://example.test/org/repo/pull/42", + ); +}); + +it("normalizes control characters and bounds error transport values", () => { + assert.strictEqual( + transportSafeSourceControlErrorValue(` owner/repo\n\t${"x".repeat(300)} `), + `owner/repo ${"x".repeat(245)}`, + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index f0602f03d14..5f93dbcaa42 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -22,6 +22,36 @@ export interface SourceControlRefSelector { readonly repository?: string; } +const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; + +/** + * Sanitizes user-provided source-control identifiers before attaching them to + * contract errors. This is intentionally narrower than request validation: it + * only strips URL secrets and bounds diagnostic values sent over transport. + */ +export function transportSafeSourceControlErrorValue(value: string): string { + let printable = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character; + } + const normalized = printable.trim().replace(/\s+/gu, " "); + + let safe = normalized; + try { + const url = new URL(normalized); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + safe = url.toString(); + } catch { + // Plain repository and change-request identifiers are not URLs. + } + + return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); +} + export function parseSourceControlOwnerRef( headSelector: string, ): SourceControlRefSelector | undefined { @@ -49,54 +79,52 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } -export interface SourceControlProviderShape { - readonly kind: SourceControlProviderKind; - readonly listChangeRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly source?: SourceControlRefSelector; - readonly headSelector: string; - readonly state: ChangeRequestState | "all"; - readonly limit?: number; - }) => Effect.Effect, SourceControlProviderError>; - readonly getChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect; - readonly createChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; - readonly baseRefName: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - export class SourceControlProvider extends Context.Service< SourceControlProvider, - SourceControlProviderShape + { + readonly kind: SourceControlProviderKind; + readonly listChangeRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly headSelector: string; + readonly state: ChangeRequestState | "all"; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly createChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly baseRefName: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } >()("t3/sourceControl/SourceControlProvider") {} diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 856d6948e09..e3a6bd1fb20 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -158,7 +158,7 @@ function isCliRemoteRefinementSpec( function probeCli(input: { readonly spec: SourceControlCliDiscoverySpec; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; }): Effect.Effect { return input.process @@ -202,7 +202,7 @@ function probeCli(input: { export function probeSourceControlProvider(input: { readonly spec: SourceControlProviderDiscoverySpec; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; }): Effect.Effect { if (input.spec.type === "api") { @@ -270,7 +270,7 @@ export function probeSourceControlProvider(input: { export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")( function* (input: { readonly specs: ReadonlyArray; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; readonly context: SourceControlProvider.SourceControlProviderContext | null; }): Effect.fn.Return { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 833956ecc7e..5c4d27e46f9 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -5,8 +5,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -37,7 +38,8 @@ function makeRegistry(input: { readonly name: string; readonly url: string; }>; - readonly process?: Partial; + readonly process?: Partial; + readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; }) { const driver = { listRemotes: () => @@ -53,25 +55,27 @@ function makeRegistry(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape), - resolve: () => - Effect.succeed({ - kind: "git", - repository: { + get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), + resolve: + input.resolve ?? + (() => + Effect.succeed({ kind: "git", - rootPath: "/repo", - metadataPath: null, - freshness: { - source: "live-local" as const, - observedAt: TEST_EPOCH, - expiresAt: Option.none(), + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, }, - }, - driver: driver as unknown as VcsDriver.VcsDriverShape, - }), + driver: driver as unknown as VcsDriver.VcsDriver["Service"], + })), }); const processLayer = Layer.mock(VcsProcess.VcsProcess)({ @@ -79,7 +83,7 @@ function makeRegistry(input: { ...input.process, }); - return SourceControlProviderRegistry.make().pipe( + return SourceControlProviderRegistry.make.pipe( Effect.provide( Layer.mergeAll( registryLayer, @@ -88,9 +92,9 @@ function makeRegistry(input: { Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( - Layer.provide(NodeServices.layer), - ), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-test-", + }).pipe(Layer.provide(NodeServices.layer)), ), ), ); @@ -120,6 +124,46 @@ it.effect("routes directly by provider kind for remote-first workflows", () => }), ); +it.effect("includes the request cwd when an unregistered provider is used", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ remotes: [] }); + const provider = yield* registry.get("unknown"); + + const error = yield* provider + .getChangeRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "getChangeRequest"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.reference, "#42"); + }), +); + +it.effect("retains VCS detection failures with structured cwd context", () => + Effect.gen(function* () { + const cause = new VcsRepositoryDetectionError({ + operation: "resolve", + cwd: "/repo", + detail: "raw VCS detection failure", + cause: new Error("raw nested failure"), + }); + const registry = yield* makeRegistry({ + remotes: [], + resolve: () => Effect.fail(cause), + }); + + const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "detectProvider"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.detail, "Failed to detect source control provider."); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.message), false); + }), +); + it.effect("routes GitLab remotes to the GitLab provider", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 08f794d1f5c..fb70d677e43 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -16,7 +16,11 @@ import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvide import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { + probeSourceControlProvider, + refineUnknownRemoteProvider, + type SourceControlProviderDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; import { ServerConfig } from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -26,63 +30,96 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; - readonly provider: SourceControlProvider.SourceControlProviderShape; - readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec; + readonly provider: SourceControlProvider.SourceControlProvider["Service"]; + readonly discovery: SourceControlProviderDiscoverySpec; } export interface SourceControlProviderHandle { - readonly provider: SourceControlProvider.SourceControlProviderShape; + readonly provider: SourceControlProvider.SourceControlProvider["Service"]; readonly context: SourceControlProvider.SourceControlProviderContext | null; } -export interface SourceControlProviderRegistryShape { - readonly get: ( - kind: SourceControlProviderKind, - ) => Effect.Effect; - readonly resolveHandle: (input: { - readonly cwd: string; - }) => Effect.Effect; - readonly resolve: (input: { - readonly cwd: string; - }) => Effect.Effect; - readonly discover: Effect.Effect>; -} - export class SourceControlProviderRegistry extends Context.Service< SourceControlProviderRegistry, - SourceControlProviderRegistryShape + { + readonly get: ( + kind: SourceControlProviderKind, + ) => Effect.Effect< + SourceControlProvider.SourceControlProvider["Service"], + SourceControlProviderError + >; + readonly resolveHandle: (input: { + readonly cwd: string; + }) => Effect.Effect; + readonly resolve: (input: { + readonly cwd: string; + }) => Effect.Effect< + SourceControlProvider.SourceControlProvider["Service"], + SourceControlProviderError + >; + readonly discover: Effect.Effect>; + } >()("t3/sourceControl/SourceControlProviderRegistry") {} function unsupportedProvider( kind: SourceControlProviderKind, -): SourceControlProvider.SourceControlProviderShape { - const unsupported = (operation: string) => - Effect.fail( +): SourceControlProvider.SourceControlProvider["Service"] { + return SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests: (input) => new SourceControlProviderError({ provider: kind, - operation, + operation: "listChangeRequests", + cwd: input.cwd, + detail: `No ${kind} source control provider is registered.`, + }), + getChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), + detail: `No ${kind} source control provider is registered.`, + }), + createChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "createChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.headSelector), + detail: `No ${kind} source control provider is registered.`, + }), + getRepositoryCloneUrls: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), + detail: `No ${kind} source control provider is registered.`, + }), + createRepository: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "createRepository", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), + detail: `No ${kind} source control provider is registered.`, + }), + getDefaultBranch: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getDefaultBranch", + cwd: input.cwd, + detail: `No ${kind} source control provider is registered.`, + }), + checkoutChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), detail: `No ${kind} source control provider is registered.`, }), - ); - - return SourceControlProvider.SourceControlProvider.of({ - kind, - listChangeRequests: () => unsupported("listChangeRequests"), - getChangeRequest: () => unsupported("getChangeRequest"), - createChangeRequest: () => unsupported("createChangeRequest"), - getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), - createRepository: () => unsupported("createRepository"), - getDefaultBranch: () => unsupported("getDefaultBranch"), - checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), - }); -} - -function providerDetectionError(operation: string, cwd: string, cause: unknown) { - return new SourceControlProviderError({ - provider: "unknown", - operation, - detail: `Failed to detect source control provider for ${cwd}.`, - cause, }); } @@ -113,9 +150,9 @@ function selectProviderContext( } function bindProviderContext( - provider: SourceControlProvider.SourceControlProviderShape, + provider: SourceControlProvider.SourceControlProvider["Service"], context: SourceControlProvider.SourceControlProviderContext | null, -): SourceControlProvider.SourceControlProviderShape { +): SourceControlProvider.SourceControlProvider["Service"] { if (context === null) { return provider; } @@ -163,24 +200,42 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const providers = new Map< SourceControlProviderKind, - SourceControlProvider.SourceControlProviderShape + SourceControlProvider.SourceControlProvider["Service"] >(registrations.map((registration) => [registration.kind, registration.provider])); const discoverySpecs = registrations.map((registration) => registration.discovery); - const get: SourceControlProviderRegistryShape["get"] = (kind) => + const get: SourceControlProviderRegistry["Service"]["get"] = (kind) => Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( function* (cwd: string) { - const handle = yield* vcsRegistry - .resolve({ cwd }) - .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); - const remotes = yield* handle.driver - .listRemotes(cwd) - .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + const handle = yield* vcsRegistry.resolve({ cwd }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "unknown", + operation: "detectProvider", + cwd, + detail: "Failed to detect source control provider.", + cause: error, + }), + ), + ); + const remotes = yield* handle.driver.listRemotes(cwd).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "unknown", + operation: "detectProvider", + cwd, + detail: "Failed to detect source control provider.", + cause: error, + }), + ), + ); const context = selectProviderContext(remotes.remotes); - return yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({ + return yield* refineUnknownRemoteProvider({ specs: discoverySpecs, process, cwd, @@ -198,7 +253,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), }); - const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => + const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => Cache.get(providerContextCache, input.cwd).pipe( Effect.map((context) => { const kind = context?.provider.kind ?? "unknown"; @@ -216,7 +271,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), discover: Effect.all( discoverySpecs.map((spec) => - SourceControlProviderDiscovery.probeSourceControlProvider({ + probeSourceControlProvider({ spec, process, cwd: config.cwd, @@ -228,12 +283,12 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }, ); -export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { - const github = yield* GitHubSourceControlProvider.make(); - const gitlab = yield* GitLabSourceControlProvider.make(); - const bitbucket = yield* BitbucketSourceControlProvider.make(); - const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery(); - const azureDevOps = yield* AzureDevOpsSourceControlProvider.make(); +export const make = Effect.gen(function* () { + const github = yield* GitHubSourceControlProvider.make; + const gitlab = yield* GitLabSourceControlProvider.make; + const bitbucket = yield* BitbucketSourceControlProvider.make; + const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; + const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; return yield* makeWithProviders([ { kind: "github", @@ -258,4 +313,4 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () ]); }); -export const layer = Layer.effect(SourceControlProviderRegistry, make()); +export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 811b55c70a3..861da9a10e0 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,13 +1,15 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; +import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -20,8 +22,8 @@ const CLONE_URLS = { }; function makeProvider( - overrides: Partial = {}, -): SourceControlProvider.SourceControlProviderShape { + overrides: Partial = {}, +): SourceControlProvider.SourceControlProvider["Service"] { const unsupported = (operation: string) => Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< never, @@ -52,10 +54,11 @@ function processOutput(): GitVcsDriver.ExecuteGitResult { } function makeLayer(input: { - readonly provider?: SourceControlProvider.SourceControlProviderShape; - readonly git?: Partial; + readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; + readonly git?: Partial; + readonly fileSystem?: FileSystem.FileSystem; }) { - return SourceControlRepositoryService.layer.pipe( + const serviceLayer = SourceControlRepositoryService.layer.pipe( Layer.provide( Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ get: () => Effect.succeed(input.provider ?? makeProvider()), @@ -75,9 +78,20 @@ function makeLayer(input: { ...input.git, }), ), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provide( + ServerConfig.layerTest( + process.cwd(), + input.fileSystem ? "/tmp/t3-source-control-repos" : { prefix: "t3-source-control-repos-" }, + ), + ), ); + + return input.fileSystem + ? serviceLayer.pipe( + Layer.provide(Layer.succeed(FileSystem.FileSystem, input.fileSystem)), + Layer.provideMerge(NodePath.layer), + ) + : serviceLayer.pipe(Layer.provideMerge(NodeServices.layer)); } it.effect("looks up repositories through the requested provider without search", () => { @@ -103,6 +117,39 @@ it.effect("looks up repositories through the requested provider without search", }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("preserves provider failures without deriving the repository message from them", () => { + const providerCause = new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: "/workspace", + repository: "octocat/t3code", + detail: "credential token abc123 was rejected", + }); + const provider = makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(providerCause), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.lookupRepository({ + provider: "github", + repository: "octocat/t3code", + cwd: "/workspace", + }), + ); + + assert.strictEqual(error.provider, "github"); + assert.strictEqual(error.operation, "lookupRepository"); + assert.strictEqual(error.detail, "The source control operation could not be completed."); + assert.strictEqual( + error.message, + "Source control repository operation lookupRepository failed for github: The source control operation could not be completed.", + ); + assert.strictEqual(error.cause, providerCause); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -148,6 +195,38 @@ it.effect("clones a looked-up repository into the requested destination", () => }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect("preserves destination probe failures instead of treating them as missing paths", () => { + const fileSystemCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: "/restricted/t3code", + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.cloneRepository({ + remoteUrl: CLONE_URLS.sshUrl, + destinationPath: "/restricted/t3code", + }), + ); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "cloneRepository"); + assert.strictEqual(error.cause, fileSystemCause); + }).pipe( + Effect.provide( + makeLayer({ + fileSystem: FileSystem.makeNoop({ + exists: () => Effect.fail(fileSystemCause), + makeDirectory: () => Effect.void, + }), + }), + ), + ); +}); + it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 106d300ec2d..1b46369e25c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -24,58 +24,29 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); -export interface SourceControlRepositoryServiceShape { - readonly lookupRepository: ( - input: SourceControlRepositoryLookupInput, - ) => Effect.Effect; - readonly cloneRepository: ( - input: SourceControlCloneRepositoryInput, - ) => Effect.Effect; - readonly publishRepository: ( - input: SourceControlPublishRepositoryInput, - ) => Effect.Effect; -} - export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, - SourceControlRepositoryServiceShape ->()("t3/sourceControl/SourceControlRepositoryService") {} - -function detailFromUnknown(cause: unknown): string { - if (typeof cause === "object" && cause !== null) { - if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) { - return cause.detail; - } - if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) { - return cause.message; - } + { + readonly lookupRepository: ( + input: SourceControlRepositoryLookupInput, + ) => Effect.Effect; + readonly cloneRepository: ( + input: SourceControlCloneRepositoryInput, + ) => Effect.Effect; + readonly publishRepository: ( + input: SourceControlPublishRepositoryInput, + ) => Effect.Effect; } - - return "An unexpected source control error occurred."; -} - -function repositoryError(input: { - readonly operation: string; - readonly provider: SourceControlProviderKind; - readonly detail: string; - readonly cause?: unknown; -}): SourceControlRepositoryError { - return new SourceControlRepositoryError({ - provider: input.provider, - operation: input.operation, - detail: input.detail, - ...(input.cause === undefined ? {} : { cause: input.cause }), - }); -} +>()("t3/sourceControl/SourceControlRepositoryService") {} function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { return Effect.mapError((cause: unknown) => isSourceControlRepositoryError(cause) ? cause - : repositoryError({ + : new SourceControlRepositoryError({ operation, provider, - detail: detailFromUnknown(cause), + detail: "The source control operation could not be completed.", cause, }), ); @@ -116,7 +87,7 @@ function expandHomePath(input: string, path: Path.Path): string { return input; } -export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { +export const make = Effect.gen(function* () { const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const git = yield* GitVcsDriver.GitVcsDriver; @@ -132,7 +103,7 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () } return Effect.fail( - repositoryError({ + new SourceControlRepositoryError({ operation: input.operation, provider: input.provider, detail: "Choose a source control provider before continuing.", @@ -159,7 +130,7 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () function* (destinationPath: string) { const trimmed = destinationPath.trim(); if (trimmed.length === 0) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider: "unknown", detail: "Choose a destination path before cloning.", @@ -173,21 +144,22 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( function* (destinationPath: string) { const normalizedDestination = yield* normalizeDestinationPath(destinationPath); - if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) { + if (yield* fileSystem.exists(normalizedDestination)) { const entries = yield* fileSystem .readDirectory(normalizedDestination, { recursive: false }) .pipe( - Effect.mapError((cause) => - repositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Destination path already exists and is not a directory.", - cause, - }), + Effect.mapError( + (cause) => + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not a directory.", + cause, + }), ), ); if (entries.length > 0) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider: "unknown", detail: "Destination path already exists and is not empty.", @@ -224,7 +196,7 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () } if (!remoteUrl) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider, detail: "Enter a repository path or clone URL before cloning.", @@ -315,4 +287,4 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () }); }); -export const layer = Layer.effect(SourceControlRepositoryService, make()); +export const layer = Layer.effect(SourceControlRepositoryService, make); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts similarity index 89% rename from apps/server/src/telemetry/Layers/AnalyticsService.test.ts rename to apps/server/src/telemetry/AnalyticsService.test.ts index 5aa47406d9b..d69bab32feb 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -8,10 +8,9 @@ import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import { ServerConfig } from "../../config.ts"; -import { getTelemetryIdentifier } from "../Identify.ts"; -import { AnalyticsService } from "../Services/AnalyticsService.ts"; -import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts"; +import * as ServerConfig from "../config.ts"; +import { getTelemetryIdentifier } from "./Identify.ts"; +import * as AnalyticsService from "./AnalyticsService.ts"; interface RecordedBatchRequest { readonly path: string; @@ -40,11 +39,11 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-base-", }); - const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer)); + const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer)); const configLayer = ConfigProvider.layer( ConfigProvider.fromUnknown({ T3CODE_TELEMETRY_ENABLED: true, @@ -79,7 +78,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); const telemetryIdentifier = yield* getTelemetryIdentifier; assert.equal(telemetryIdentifier !== null, true); - const analytics = yield* AnalyticsService; + const analytics = yield* AnalyticsService.AnalyticsService; for (let index = 0; index < 45; index += 1) { yield* analytics.record("test.flush.drain", { index }); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts similarity index 72% rename from apps/server/src/telemetry/Layers/AnalyticsService.ts rename to apps/server/src/telemetry/AnalyticsService.ts index 0d51d7c66b1..5fdc7bdeb19 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,25 +1,26 @@ /** - * AnalyticsServiceLive - Anonymous PostHog telemetry layer. + * Anonymous PostHog telemetry service. * - * Persists a random installation-scoped anonymous id to state dir, buffers - * events in memory, and flushes batches to PostHog over Effect HttpClient. + * Persists an installation-scoped anonymous identifier, buffers events in + * memory, and flushes batches over Effect's HTTP client. * - * @module AnalyticsServiceLive + * @module AnalyticsService */ - import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { ServerConfig } from "../../config.ts"; -import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; -import { getTelemetryIdentifier } from "../Identify.ts"; -import packageJson from "../../../package.json" with { type: "json" }; +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import { getTelemetryIdentifier } from "./Identify.ts"; interface BufferedAnalyticsEvent { readonly event: string; @@ -42,10 +43,33 @@ const TelemetryEnvConfig = Config.all({ wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option), }); -const makeAnalyticsService = Effect.gen(function* () { +export class AnalyticsService extends Context.Service< + AnalyticsService, + { + /** Record an anonymous event for best-effort buffered delivery. */ + readonly record: ( + event: string, + properties?: Readonly>, + ) => Effect.Effect; + + /** Flush all currently queued telemetry events. */ + readonly flush: Effect.Effect; + } +>()("t3/telemetry/AnalyticsService") { + /** No-op layer for callers that intentionally disable telemetry. */ + static readonly layerTest = Layer.succeed( + AnalyticsService, + AnalyticsService.of({ + record: () => Effect.void, + flush: Effect.void, + }), + ); +} + +export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const identifier = yield* getTelemetryIdentifier; const bufferRef = yield* Ref.make>([]); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; @@ -79,7 +103,7 @@ const makeAnalyticsService = Effect.gen(function* () { }), ); - const sendBatch = Effect.fn("sendBatch")(function* ( + const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( events: ReadonlyArray, ) { if (!telemetryConfig.enabled || !identifier) return; @@ -109,7 +133,7 @@ const makeAnalyticsService = Effect.gen(function* () { ); }); - const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () { + const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { while (true) { const batch = yield* Ref.modify(bufferRef, (current) => { if (current.length === 0) { @@ -134,7 +158,7 @@ const makeAnalyticsService = Effect.gen(function* () { } }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); - const record: AnalyticsServiceShape["record"] = Effect.fn("record")( + const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { if (!telemetryConfig.enabled || !identifier) return; @@ -154,10 +178,9 @@ const makeAnalyticsService = Effect.gen(function* () { yield* Effect.addFinalizer(() => flush); - return { - record, - flush, - } satisfies AnalyticsServiceShape; + return AnalyticsService.of({ record, flush }); }); -export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService); +export const layer = Layer.effect(AnalyticsService, make); + +export const layerTest = AnalyticsService.layerTest; diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts new file mode 100644 index 00000000000..ab151821789 --- /dev/null +++ b/apps/server/src/telemetry/Identify.test.ts @@ -0,0 +1,172 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +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 Path from "effect/Path"; +import * as References from "effect/References"; + +import * as ServerConfig from "../config.ts"; +import * as Identify from "./Identify.ts"; + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} + +const sha256 = (value: string) => + NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex"); + +const makeCaptureLogger = (logs: CapturedLog[]) => + Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + +const findIdentityLog = ( + logs: ReadonlyArray, + source: Identify.TelemetryIdentitySource, + errorTag: string, +) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); + +it("preserves exact telemetry identity causes without deriving messages from them", () => { + const decodeCause = new Error("private nested decode details"); + const decodeError = new Identify.TelemetryIdentityDecodeError({ + source: "codex", + filePath: "/tmp/auth.json", + cause: decodeCause, + }); + const readCause = new Error("private nested read details"); + const readError = new Identify.TelemetryIdentityReadError({ + source: "anonymous", + filePath: "/tmp/anonymous-id", + cause: readCause, + }); + + assert.strictEqual(decodeError.cause, decodeCause); + assert.strictEqual(readError.cause, readCause); + assert.notInclude(decodeError.message, decodeCause.message); + assert.notInclude(readError.message, readCause.message); +}); + +it.layer(NodeServices.layer)("telemetry identity", (it) => { + it.effect("uses the persisted anonymous id when provider identities are absent", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const anonymousId = "persisted-anonymous-id"; + + yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); + + const identifier = yield* Identify.getTelemetryIdentifierForHome( + path.join(config.baseDir, "home"), + ); + + assert.equal(identifier, sha256(anonymousId)); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-anonymous-", + }), + ), + ), + ); + + it.effect("logs structured decode context and falls back from malformed Codex auth", () => { + const logs: CapturedLog[] = []; + const logger = makeCaptureLogger(logs); + + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json"); + const anonymousId = "decode-fallback-anonymous-id"; + const privateAccessToken = "private-codex-access-token"; + + yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true }); + yield* fileSystem.writeFileString( + codexAuthPath, + `{"tokens":{"access_token":"${privateAccessToken}"}}`, + ); + yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); + + const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); + + assert.equal(identifier, sha256(anonymousId)); + const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"); + assert.isDefined(decodeLog); + assert.equal( + decodeLog?.message, + `Failed to decode codex telemetry identity at '${codexAuthPath}'.`, + ); + + assert.equal(decodeLog?.annotations.filePath, codexAuthPath); + assert.equal(decodeLog?.annotations.causeKind, "schema"); + assert.notProperty(decodeLog?.annotations ?? {}, "cause"); + const errorStack = decodeLog?.annotations.errorStack; + assert.isString(errorStack); + assert.include(errorStack, "Failed to decode codex telemetry identity"); + const annotations = Object.values(decodeLog?.annotations ?? {}) + .map(String) + .join("\n"); + assert.notInclude(annotations, privateAccessToken); + }).pipe( + Effect.provide( + Layer.merge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-decode-", + }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + + it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => { + const logs: CapturedLog[] = []; + const logger = makeCaptureLogger(logs); + + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + + yield* fileSystem.makeDirectory(config.anonymousIdPath); + + const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); + + assert.isNull(identifier); + assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []); + + const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError"); + assert.isDefined(readLog); + assert.equal(readLog?.annotations.filePath, config.anonymousIdPath); + assert.equal(readLog?.annotations.causeKind, "platform"); + assert.notEqual(readLog?.annotations.platformReason, "NotFound"); + assert.notProperty(readLog?.annotations ?? {}, "cause"); + const errorStack = readLog?.annotations.errorStack; + assert.isString(errorStack); + assert.include(errorStack, "Failed to read anonymous telemetry identity"); + assert.isUndefined( + findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"), + ); + }).pipe( + Effect.provide( + Layer.merge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-read-", + }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 364273a9e1d..b6c3d0066df 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -3,10 +3,12 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.Struct({ @@ -18,60 +20,225 @@ const ClaudeJsonSchema = Schema.Struct({ userID: Schema.String, }); -class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} +export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); +export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; -const hash = (value: string) => +export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( + "TelemetryIdentityReadError", + { + source: TelemetryIdentitySource, + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( + "TelemetryIdentityDecodeError", + { + source: Schema.Literals(["codex", "claude"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryAnonymousIdGenerationError extends Schema.TaggedErrorClass()( + "TelemetryAnonymousIdGenerationError", + { + source: Schema.Literal("anonymous"), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`; + } +} + +export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedErrorClass()( + "TelemetryAnonymousIdPersistenceError", + { + source: Schema.Literal("anonymous"), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryIdentityHashError extends Schema.TaggedErrorClass()( + "TelemetryIdentityHashError", + { + source: TelemetryIdentitySource, + algorithm: Schema.Literal("SHA-256"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to hash ${this.source} telemetry identity with ${this.algorithm}.`; + } +} + +type TelemetryIdentityError = + | TelemetryIdentityReadError + | TelemetryIdentityDecodeError + | TelemetryAnonymousIdGenerationError + | TelemetryAnonymousIdPersistenceError + | TelemetryIdentityHashError; + +const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)); +const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)); + +function isNotFoundError(error: PlatformError.PlatformError): boolean { + return error.reason._tag === "NotFound"; +} + +const getTelemetryIdentityCauseAnnotations = (cause: unknown) => { + if (cause instanceof PlatformError.PlatformError) { + return { + causeKind: "platform", + platformReason: cause.reason._tag, + }; + } + if (cause instanceof Schema.SchemaError) { + return { causeKind: "schema" }; + } + return { causeKind: "other" }; +}; + +const logTelemetryIdentityError = (error: TelemetryIdentityError) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + source: error.source, + ...("filePath" in error ? { filePath: error.filePath } : {}), + ...getTelemetryIdentityCauseAnnotations(error.cause), + ...(error.stack === undefined ? {} : { errorStack: error.stack }), + }), + ); + +const readIdentityFile = ( + fileSystem: FileSystem.FileSystem, + source: TelemetryIdentitySource, + filePath: string, +) => + fileSystem.readFileString(filePath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + isNotFoundError(cause) + ? Effect.succeed(Option.none()) + : Effect.fail( + new TelemetryIdentityReadError({ + source, + filePath, + cause, + }), + ), + }), + ); + +const hash = (source: TelemetryIdentitySource, value: string) => Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))), Effect.map(Encoding.encodeHex), Effect.mapError( (cause) => - new IdentifyUserError({ - message: "Failed to hash identifier", + new TelemetryIdentityHashError({ + source, + algorithm: "SHA-256", cause, }), ), ); -const getCodexAccountId = Effect.gen(function* () { +const getCodexAccountId = Effect.fn("TelemetryIdentity.getCodexAccountId")(function* ( + homeDirectory: string, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json"); - const authJson = yield* Effect.flatMap( - fileSystem.readFileString(authJsonPath), - Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), + const authJsonPath = path.join(homeDirectory, ".codex", "auth.json"); + const encoded = yield* readIdentityFile(fileSystem, "codex", authJsonPath); + if (Option.isNone(encoded)) { + return Option.none(); + } + const authJson = yield* decodeCodexAuthJson(encoded.value).pipe( + Effect.mapError( + (cause) => + new TelemetryIdentityDecodeError({ + source: "codex", + filePath: authJsonPath, + cause, + }), + ), ); - return authJson.tokens.account_id; + return Option.some(authJson.tokens.account_id); }); -const getClaudeUserId = Effect.gen(function* () { +const getClaudeUserId = Effect.fn("TelemetryIdentity.getClaudeUserId")(function* ( + homeDirectory: string, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json"); - const claudeJson = yield* Effect.flatMap( - fileSystem.readFileString(claudeJsonPath), - Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), + const claudeJsonPath = path.join(homeDirectory, ".claude.json"); + const encoded = yield* readIdentityFile(fileSystem, "claude", claudeJsonPath); + if (Option.isNone(encoded)) { + return Option.none(); + } + const claudeJson = yield* decodeClaudeJson(encoded.value).pipe( + Effect.mapError( + (cause) => + new TelemetryIdentityDecodeError({ + source: "claude", + filePath: claudeJsonPath, + cause, + }), + ), ); - return claudeJson.userID; + return Option.some(claudeJson.userID); }); const upsertAnonymousId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const { anonymousIdPath } = yield* ServerConfig; - - const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe( - Effect.catch(() => - Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.tap((randomId) => fileSystem.writeFileString(anonymousIdPath, randomId)), - ), + const { anonymousIdPath } = yield* ServerConfig.ServerConfig; + + const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath); + if (Option.isSome(existing)) { + return existing.value; + } + + const anonymousId = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4), + Effect.mapError( + (cause) => + new TelemetryAnonymousIdGenerationError({ + source: "anonymous", + filePath: anonymousIdPath, + cause, + }), + ), + ); + yield* fileSystem.writeFileString(anonymousIdPath, anonymousId).pipe( + Effect.mapError( + (cause) => + new TelemetryAnonymousIdPersistenceError({ + source: "anonymous", + filePath: anonymousIdPath, + cause, + }), ), ); @@ -84,24 +251,53 @@ const upsertAnonymousId = Effect.gen(function* () { * 2. ~/.claude.json userID * 3. ~/.t3/telemetry/anonymous-id */ -export const getTelemetryIdentifier = Effect.gen(function* () { - const codexAccountId = yield* Effect.result(getCodexAccountId); - if (codexAccountId._tag === "Success") { - return yield* hash(codexAccountId.success); - } +export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")( + function* (homeDirectory: string) { + const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe( + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryIdentityDecodeError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(codexAccountId)) { + return yield* hash("codex", codexAccountId.value); + } - const claudeUserId = yield* Effect.result(getClaudeUserId); - if (claudeUserId._tag === "Success") { - return yield* hash(claudeUserId.success); - } + const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe( + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryIdentityDecodeError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(claudeUserId)) { + return yield* hash("claude", claudeUserId.value); + } - const anonymousId = yield* Effect.result(upsertAnonymousId); - if (anonymousId._tag === "Success") { - return yield* hash(anonymousId.success); - } + const anonymousId = yield* upsertAnonymousId.pipe( + Effect.map(Option.some), + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryAnonymousIdGenerationError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryAnonymousIdPersistenceError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(anonymousId)) { + return yield* hash("anonymous", anonymousId.value); + } - return null; -}).pipe( - Effect.tapError((error) => Effect.logWarning("Failed to get identifier", { cause: error })), + return null; + }, + Effect.tapError(logTelemetryIdentityError), Effect.orElseSucceed(() => null), ); + +export const getTelemetryIdentifier = Effect.suspend(() => + getTelemetryIdentifierForHome(NodeOS.homedir()), +); diff --git a/apps/server/src/telemetry/Services/AnalyticsService.ts b/apps/server/src/telemetry/Services/AnalyticsService.ts index a2717c790dc..879a1de7cdb 100644 --- a/apps/server/src/telemetry/Services/AnalyticsService.ts +++ b/apps/server/src/telemetry/Services/AnalyticsService.ts @@ -1,35 +1,2 @@ -/** - * AnalyticsService - Anonymous telemetry capture contract. - * - * Provides a best-effort event API for runtime telemetry and a strict - * `captureImmediate` method for call sites that need explicit error handling. - * - * @module AnalyticsService - */ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Context from "effect/Context"; - -export interface AnalyticsServiceShape { - /** - * Capture an event immediately; returns typed failure when capture fails. - */ - readonly record: ( - event: string, - properties?: Readonly>, - ) => Effect.Effect; - - /** - * Flush queued telemetry. - */ - readonly flush: Effect.Effect; -} - -export class AnalyticsService extends Context.Service()( - "t3/telemetry/Services/AnalyticsService", -) { - static readonly layerTest = Layer.succeed(AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }); -} +// Compatibility shim for the intentionally excluded orchestration harness. +export { AnalyticsService } from "../AnalyticsService.ts"; diff --git a/apps/server/src/terminal/BunPtyAdapter.test.ts b/apps/server/src/terminal/BunPtyAdapter.test.ts new file mode 100644 index 00000000000..e04a54e6d33 --- /dev/null +++ b/apps/server/src/terminal/BunPtyAdapter.test.ts @@ -0,0 +1,44 @@ +import { assert, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import * as BunPtyAdapter from "./BunPtyAdapter.ts"; + +it("describes unavailable Bun PTY operations structurally", () => { + const error = new BunPtyAdapter.BunPtyOperationUnavailableError({ + operation: "resize", + pid: 42, + }); + + expect(error).toMatchObject({ + _tag: "BunPtyOperationUnavailableError", + operation: "resize", + pid: 42, + }); + expect(error.message).toBe("Bun PTY resize is unavailable for process 42."); +}); + +it.effect("reports unsupported platforms with a structured startup defect", () => + Effect.gen(function* () { + const exit = yield* BunPtyAdapter.make().pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + const error = Cause.squash(exit.cause); + assert.instanceOf(error, BunPtyAdapter.BunPtyUnsupportedPlatformError); + expect(error).toMatchObject({ + _tag: "BunPtyUnsupportedPlatformError", + platform: "win32", + }); + expect(error.message).toBe( + "Bun PTY terminal support is unavailable on win32. Please use Node.js (e.g. by running `npx t3`) instead.", + ); + } + }), +); diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/BunPtyAdapter.ts similarity index 58% rename from apps/server/src/terminal/Layers/BunPTY.ts rename to apps/server/src/terminal/BunPtyAdapter.ts index 82ea1dcb9b9..88b68940de1 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -2,13 +2,37 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { PtyAdapter } from "../Services/PTY.ts"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; -class BunPtyProcess implements PtyProcess { +import * as PtyAdapter from "./PtyAdapter.ts"; + +export class BunPtyUnsupportedPlatformError extends Schema.TaggedErrorClass()( + "BunPtyUnsupportedPlatformError", + { + platform: Schema.Literal("win32"), + }, +) { + override get message(): string { + return `Bun PTY terminal support is unavailable on ${this.platform}. Please use Node.js (e.g. by running \`npx t3\`) instead.`; + } +} + +export class BunPtyOperationUnavailableError extends Schema.TaggedErrorClass()( + "BunPtyOperationUnavailableError", + { + operation: Schema.Literals(["write", "resize"]), + pid: Schema.Number, + }, +) { + override get message(): string { + return `Bun PTY ${this.operation} is unavailable for process ${this.pid}.`; + } +} + +class BunPtyProcess implements PtyAdapter.PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); private readonly decoder = new TextDecoder(); private readonly process: Bun.Subprocess; private didExit = false; @@ -33,14 +57,14 @@ class BunPtyProcess implements PtyProcess { write(data: string): void { if (!this.process.terminal) { - throw new Error("Bun PTY terminal handle is unavailable"); + throw new BunPtyOperationUnavailableError({ operation: "write", pid: this.pid }); } this.process.terminal.write(data); } resize(cols: number, rows: number): void { if (!this.process.terminal?.resize) { - throw new Error("Bun PTY resize is unavailable"); + throw new BunPtyOperationUnavailableError({ operation: "resize", pid: this.pid }); } this.process.terminal.resize(cols, rows); } @@ -60,7 +84,7 @@ class BunPtyProcess implements PtyProcess { }; } - onExit(callback: (event: PtyExitEvent) => void): () => void { + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); @@ -76,7 +100,7 @@ class BunPtyProcess implements PtyProcess { } } - private emitExit(event: PtyExitEvent): void { + private emitExit(event: PtyAdapter.PtyExitEvent): void { if (this.didExit) return; this.didExit = true; @@ -93,18 +117,15 @@ class BunPtyProcess implements PtyProcess { } } -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - if (platform === "win32") { - return yield* Effect.die( - "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", - ); - } - return { - spawn: (input) => - Effect.sync(() => { +export const make = Effect.fn("BunPtyAdapter.make")(function* () { + const platform = yield* HostProcessPlatform; + if (platform === "win32") { + return yield* Effect.die(new BunPtyUnsupportedPlatformError({ platform })); + } + return PtyAdapter.PtyAdapter.of({ + spawn: (input) => + Effect.try({ + try: () => { let processHandle: BunPtyProcess | null = null; const command = [input.shell, ...(input.args ?? [])]; const subprocess = Bun.spawn(command, { @@ -120,7 +141,15 @@ export const layer = Layer.effect( }); processHandle = new BunPtyProcess(subprocess); return processHandle; - }), - } satisfies PtyAdapterShape; - }), -); + }, + catch: (cause) => + new PtyAdapter.PtySpawnError({ + adapter: "bun", + shell: input.shell, + cause, + }), + }), + }); +}); + +export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts deleted file mode 100644 index fe00f099d9c..00000000000 --- a/apps/server/src/terminal/Layers/Manager.ts +++ /dev/null @@ -1,2591 +0,0 @@ -import { - DEFAULT_TERMINAL_ID, - ProjectId, - ThreadId, - type TerminalAttachInput, - type TerminalAttachStreamEvent, - type TerminalEvent, - type TerminalMetadataStreamEvent, - type TerminalOpenInput, - type TerminalRestartInput, - type TerminalSessionSnapshot, - type TerminalSessionStatus, - type TerminalSummary, -} from "@t3tools/contracts"; -import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isManagedRuntimeEnvKey } from "../../launchEnv/launchEnvUtils.ts"; -import { LaunchEnv, type LaunchEnvShape } from "../../launchEnv/Services/LaunchEnv.ts"; -import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -import * as Equal from "effect/Equal"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as SynchronizedRef from "effect/SynchronizedRef"; - -import { ServerConfig } from "../../config.ts"; -import { - increment, - terminalRestartsTotal, - terminalSessionsTotal, -} from "../../observability/Metrics.ts"; -import * as ProcessRunner from "../../processRunner.ts"; -import * as PortScanner from "../../preview/PortScanner.ts"; -import { - TerminalCwdError, - TerminalHistoryError, - TerminalManager, - TerminalNotRunningError, - TerminalSessionLookupError, - type TerminalManagerShape, -} from "../Services/Manager.ts"; -import { - PtyAdapter, - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; - -const DEFAULT_HISTORY_LINE_LIMIT = 5_000; -const DEFAULT_PERSIST_DEBOUNCE_MS = 40; -const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; -const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; -const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; -const DEFAULT_OPEN_COLS = 120; -const DEFAULT_OPEN_ROWS = 30; -const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); -const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -const MAX_TERMINAL_LABEL_LENGTH = 128; - -class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( - "TerminalSubprocessCheckError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - terminalPid: Schema.Number, - command: Schema.Literals(["powershell", "pgrep", "ps"]), - }, -) {} - -class TerminalProcessSignalError extends Schema.TaggedErrorClass()( - "TerminalProcessSignalError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - signal: Schema.Literals(["SIGTERM", "SIGKILL"]), - }, -) {} - -interface TerminalSubprocessInspectResult { - readonly hasRunningSubprocess: boolean; - readonly childCommand: string | null; - readonly processIds: ReadonlyArray; -} - -interface TerminalSubprocessInspector { - ( - terminalPid: number, - ): Effect.Effect; -} - -interface ShellCandidate { - shell: string; - args?: string[]; -} - -interface TerminalStartInput { - threadId: string; - terminalId: string; - cwd: string; - worktreePath?: string | null; - cols: number; - rows: number; - env?: Record; -} - -interface TerminalSessionState { - threadId: string; - terminalId: string; - cwd: string; - worktreePath: string | null; - status: TerminalSessionStatus; - pid: number | null; - history: string; - pendingHistoryControlSequence: string; - pendingProcessEvents: Array; - pendingProcessEventIndex: number; - processEventDrainRunning: boolean; - exitCode: number | null; - exitSignal: number | null; - updatedAt: string; - eventSequence: number; - cols: number; - rows: number; - process: PtyProcess | null; - unsubscribeData: (() => void) | null; - unsubscribeExit: (() => void) | null; - hasRunningSubprocess: boolean; - /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ - childCommandLabel: string | null; - runtimeEnv: Record | null; -} - -interface PersistHistoryRequest { - history: string; - immediate: boolean; -} - -type PendingProcessEvent = { type: "output"; data: string } | { type: "exit"; event: PtyExitEvent }; - -type DrainProcessEventAction = - | { type: "idle" } - | { - type: "output"; - threadId: string; - terminalId: string; - sequence: number; - history: string | null; - data: string; - } - | { - type: "exit"; - process: PtyProcess | null; - threadId: string; - terminalId: string; - sequence: number; - exitCode: number | null; - exitSignal: number | null; - }; - -interface TerminalManagerState { - sessions: Map; - killFibers: Map>; -} - -function truncateTerminalWireLabel(value: string): string { - if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; - return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); -} - -function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { - let trimmed = raw.trim(); - if (trimmed.length === 0) return null; - if ( - (trimmed.startsWith("[") && trimmed.endsWith("]")) || - (trimmed.startsWith("(") && trimmed.endsWith(")")) - ) { - trimmed = trimmed.slice(1, -1).trim(); - } - const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); - if (firstToken.length === 0) return null; - const separators = platform === "win32" ? /[\\/]/ : /\//; - const base = firstToken.split(separators).at(-1) ?? firstToken; - const withoutExe = - platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; - return withoutExe.length > 0 ? withoutExe : null; -} - -function terminalWireLabel(session: TerminalSessionState): string { - if (session.hasRunningSubprocess && session.childCommandLabel) { - const trimmed = session.childCommandLabel.trim(); - if (trimmed.length > 0) { - return truncateTerminalWireLabel(trimmed); - } - } - return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); -} - -function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { - return { - threadId: session.threadId, - terminalId: session.terminalId, - cwd: session.cwd, - worktreePath: session.worktreePath, - status: session.status, - pid: session.pid, - history: session.history, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - label: terminalWireLabel(session), - updatedAt: session.updatedAt, - sequence: session.eventSequence, - }; -} - -function summary(session: TerminalSessionState): TerminalSummary { - return { - threadId: session.threadId, - terminalId: session.terminalId, - cwd: session.cwd, - worktreePath: session.worktreePath, - status: session.status, - pid: session.pid, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - hasRunningSubprocess: session.hasRunningSubprocess, - label: terminalWireLabel(session), - updatedAt: session.updatedAt, - }; -} - -function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { - switch (event.type) { - case "started": - case "restarted": - case "exited": - case "closed": - case "error": - case "activity": - return true; - case "output": - case "cleared": - return false; - } -} - -function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null { - switch (event.type) { - case "started": - return { - type: "snapshot", - snapshot: event.snapshot, - }; - case "output": - case "exited": - case "closed": - case "error": - case "cleared": - case "restarted": - case "activity": - return event; - } -} - -function isDuplicateAttachSnapshotEvent( - event: TerminalEvent, - initialSnapshot: TerminalSessionSnapshot, -) { - return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" - ? event.sequence <= initialSnapshot.sequence - : event.type === "started" && - event.snapshot.threadId === initialSnapshot.threadId && - event.snapshot.terminalId === initialSnapshot.terminalId && - event.snapshot.updatedAt <= initialSnapshot.updatedAt; -} - -function advanceEventSequence(session: TerminalSessionState): { - readonly updatedAt: string; - readonly sequence: number; -} { - const updatedAt = DateTime.formatIso(DateTime.nowUnsafe()); - session.eventSequence += 1; - session.updatedAt = updatedAt; - return { updatedAt, sequence: session.eventSequence }; -} - -function cleanupProcessHandles(session: TerminalSessionState): void { - session.unsubscribeData?.(); - session.unsubscribeData = null; - session.unsubscribeExit?.(); - session.unsubscribeExit = null; -} - -function enqueueProcessEvent( - session: TerminalSessionState, - expectedPid: number, - event: PendingProcessEvent, -): boolean { - if (!session.process || session.status !== "running" || session.pid !== expectedPid) { - return false; - } - - session.pendingProcessEvents.push(event); - if (session.processEventDrainRunning) { - return false; - } - - session.processEventDrainRunning = true; - return true; -} - -function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { - if (platform === "win32") { - return "pwsh.exe"; - } - return env.SHELL ?? "bash"; -} - -function normalizeShellCommand( - value: string | undefined, - platform: NodeJS.Platform, -): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - if (platform === "win32") { - return trimmed; - } - - const firstToken = trimmed.split(/\s+/g)[0]?.trim(); - if (!firstToken) return null; - return firstToken.replace(/^['"]|['"]$/g, ""); -} - -function basenameForPlatform(command: string, platform: NodeJS.Platform): string { - const normalized = - platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); - const parts = normalized - .split(platform === "win32" ? /\\+/ : /\/+/) - .filter((part) => part.length > 0); - return parts.at(-1) ?? normalized; -} - -function joinWindowsPath(...parts: ReadonlyArray): string { - return parts - .map((part, index) => { - if (index === 0) return part.replace(/[\\/]+$/g, ""); - return part.replace(/^[\\/]+|[\\/]+$/g, ""); - }) - .filter((part) => part.length > 0) - .join("\\"); -} - -function shellCandidateFromCommand( - command: string | null, - platform: NodeJS.Platform, -): ShellCandidate | null { - if (!command || command.length === 0) return null; - const shellName = basenameForPlatform(command, platform).toLowerCase(); - if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { - return { shell: command, args: ["-NoLogo"] }; - } - if (platform !== "win32" && shellName === "zsh") { - return { shell: command, args: ["-o", "nopromptsp"] }; - } - return { shell: command }; -} - -function windowsSystemRoot(env: NodeJS.ProcessEnv): string { - return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; -} - -function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { - return joinWindowsPath( - windowsSystemRoot(env), - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); -} - -function windowsCmdPath(env: NodeJS.ProcessEnv): string { - return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); -} - -function formatShellCandidate(candidate: ShellCandidate): string { - if (!candidate.args || candidate.args.length === 0) return candidate.shell; - return `${candidate.shell} ${candidate.args.join(" ")}`; -} - -function uniqueShellCandidates(candidates: Array): ShellCandidate[] { - const seen = new Set(); - const ordered: ShellCandidate[] = []; - for (const candidate of candidates) { - if (!candidate) continue; - const key = formatShellCandidate(candidate); - if (seen.has(key)) continue; - seen.add(key); - ordered.push(candidate); - } - return ordered; -} - -function resolveShellCandidates( - shellResolver: () => string, - platform: NodeJS.Platform, - env: NodeJS.ProcessEnv, -): ShellCandidate[] { - const requested = shellCandidateFromCommand( - normalizeShellCommand(shellResolver(), platform), - platform, - ); - - if (platform === "win32") { - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand("pwsh.exe", platform), - shellCandidateFromCommand(windowsPowerShellPath(env), platform), - shellCandidateFromCommand("powershell.exe", platform), - shellCandidateFromCommand(env.ComSpec ?? null, platform), - shellCandidateFromCommand(windowsCmdPath(env), platform), - shellCandidateFromCommand("cmd.exe", platform), - ]); - } - - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), - shellCandidateFromCommand("/bin/zsh", platform), - shellCandidateFromCommand("/bin/bash", platform), - shellCandidateFromCommand("/bin/sh", platform), - shellCandidateFromCommand("zsh", platform), - shellCandidateFromCommand("bash", platform), - shellCandidateFromCommand("sh", platform), - ]); -} - -function isRetryableShellSpawnError(error: PtySpawnError): boolean { - const queue: unknown[] = [error]; - const seen = new Set(); - const messages: string[] = []; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current || seen.has(current)) { - continue; - } - seen.add(current); - - if (typeof current === "string") { - messages.push(current); - continue; - } - - if (current instanceof Error) { - messages.push(current.message); - if (current.cause) { - queue.push(current.cause); - } - continue; - } - - if (typeof current === "object") { - const value = current as { message?: unknown; cause?: unknown }; - if (typeof value.message === "string") { - messages.push(value.message); - } - if (value.cause) { - queue.push(value.cause); - } - } - } - - const message = messages.join(" ").toLowerCase(); - return ( - message.includes("posix_spawnp failed") || - message.includes("enoent") || - message.includes("not found") || - message.includes("file not found") || - message.includes("no such file") - ); -} - -function parseFirstChildPidFromPgrep(stdout: string): number | null { - for (const line of stdout.split(/\r?\n/g)) { - const n = Number.parseInt(line.trim(), 10); - if (Number.isInteger(n) && n > 0) { - return n; - } - } - return null; -} - -function windowsInspectSubprocess( - terminalPid: number, - platform: NodeJS.Platform, -): Effect.Effect< - TerminalSubprocessInspectResult, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner -> { - const command = - 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; - return Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; - return yield* processRunner.run({ - // powershell.exe is a real executable — never spawn it through cmd.exe - // shell mode, which would re-tokenize the `-Command` payload (pipes, - // semicolons) before PowerShell ever sees it. - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: "1500 millis", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - }).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processNameById = new Map(); - const childrenByParent = new Map(); - for (const line of result.stdout.split(/\r?\n/g)) { - const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); - const pid = Number(pidRaw); - const parentPid = Number(parentPidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; - processNameById.set(pid, nameRaw?.trim() ?? ""); - const children = childrenByParent.get(parentPid) ?? []; - children.push(pid); - childrenByParent.set(parentPid, children); - } - const directChildren = childrenByParent.get(terminalPid) ?? []; - const childPid = directChildren[0]; - if (childPid === undefined) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processIds = new Set([terminalPid]); - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const pid of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(pid)) continue; - processIds.add(pid); - pending.push(pid); - } - } - const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - } as const; - }), - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect Windows terminal subprocesses.", - cause, - terminalPid, - command: "powershell", - }), - ), - ); -} - -const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( - terminalPid: number, - platform: NodeJS.Platform, -): Effect.fn.Return< - TerminalSubprocessInspectResult, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner -> { - const processRunner = yield* ProcessRunner.ProcessRunner; - const runPgrep = processRunner - .run({ - command: "pgrep", - args: ["-P", String(terminalPid)], - timeout: "1 second", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with pgrep.", - cause, - terminalPid, - command: "pgrep", - }), - ), - ); - - const runPs = processRunner - .run({ - command: "ps", - args: ["-eo", "pid=,ppid="], - timeout: "1 second", - maxOutputBytes: 262_144, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with ps.", - cause, - terminalPid, - command: "ps", - }), - ), - ); - - let childPid: number | null = null; - - const pgrepResult = yield* Effect.exit(runPgrep); - if (pgrepResult._tag === "Success") { - if (pgrepResult.value.code === 0) { - childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); - } else if (pgrepResult.value.code === 1) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - } - - if (childPid === null) { - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - if (ppid === terminalPid) { - childPid = pid; - break; - } - } - } - - if (childPid === null) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - - const runComm = processRunner.run({ - command: "ps", - args: ["-p", String(childPid), "-o", "comm="], - timeout: "1 second", - maxOutputBytes: 8_192, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - - const commResult = yield* Effect.exit(runComm); - let rawComm: string | null = null; - if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { - rawComm = commResult.value.stdout.trim(); - } - - if (!rawComm || rawComm.length === 0) { - const runArgs = processRunner.run({ - command: "ps", - args: ["-p", String(childPid), "-o", "args="], - timeout: "1 second", - maxOutputBytes: 16_384, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - const argsResult = yield* Effect.exit(runArgs); - if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { - const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; - rawComm = first.length > 0 ? first : null; - } - } - - const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; - const processIds = new Set([terminalPid]); - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Success" && psResult.value.code === 0) { - const childrenByParent = new Map(); - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - const children = childrenByParent.get(ppid) ?? []; - children.push(pid); - childrenByParent.set(ppid, children); - } - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const child of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(child)) continue; - processIds.add(child); - pending.push(child); - } - } - } else { - processIds.add(childPid); - } - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - }; -}); - -function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { - return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - if (platform === "win32") { - return yield* windowsInspectSubprocess(terminalPid, platform); - } - return yield* posixInspectSubprocess(terminalPid, platform); - }); -} - -function capHistory(history: string, maxLines: number): string { - if (history.length === 0) return history; - const hasTrailingNewline = history.endsWith("\n"); - const lines = history.split("\n"); - if (hasTrailingNewline) { - lines.pop(); - } - if (lines.length <= maxLines) return history; - const capped = lines.slice(lines.length - maxLines).join("\n"); - return hasTrailingNewline ? `${capped}\n` : capped; -} - -function isCsiFinalByte(codePoint: number): boolean { - return codePoint >= 0x40 && codePoint <= 0x7e; -} - -function shouldStripCsiSequence(body: string, finalByte: string): boolean { - if (finalByte === "n") { - return true; - } - if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { - return true; - } - if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { - return true; - } - return false; -} - -function shouldStripOscSequence(content: string): boolean { - return /^(10|11|12);(?:\?|rgb:)/.test(content); -} - -function stripStringTerminator(value: string): string { - if (value.endsWith("\u001b\\")) { - return value.slice(0, -2); - } - const lastCharacter = value.at(-1); - if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { - return value.slice(0, -1); - } - return value; -} - -function findStringTerminatorIndex(input: string, start: number): number | null { - for (let index = start; index < input.length; index += 1) { - const codePoint = input.charCodeAt(index); - if (codePoint === 0x07 || codePoint === 0x9c) { - return index + 1; - } - if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { - return index + 2; - } - } - return null; -} - -function isEscapeIntermediateByte(codePoint: number): boolean { - return codePoint >= 0x20 && codePoint <= 0x2f; -} - -function isEscapeFinalByte(codePoint: number): boolean { - return codePoint >= 0x30 && codePoint <= 0x7e; -} - -function findEscapeSequenceEndIndex(input: string, start: number): number | null { - let cursor = start; - while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { - cursor += 1; - } - if (cursor >= input.length) { - return null; - } - return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; -} - -function sanitizeTerminalHistoryChunk( - pendingControlSequence: string, - data: string, -): { visibleText: string; pendingControlSequence: string } { - const input = `${pendingControlSequence}${data}`; - let visibleText = ""; - let index = 0; - - const append = (value: string) => { - visibleText += value; - }; - - while (index < input.length) { - const codePoint = input.charCodeAt(index); - - if (codePoint === 0x1b) { - const nextCodePoint = input.charCodeAt(index + 1); - if (Number.isNaN(nextCodePoint)) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - - if (nextCodePoint === 0x5b) { - let cursor = index + 2; - while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { - const sequence = input.slice(index, cursor + 1); - const body = input.slice(index + 2, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } - index = cursor + 1; - break; - } - cursor += 1; - } - if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - continue; - } - - if ( - nextCodePoint === 0x5d || - nextCodePoint === 0x50 || - nextCodePoint === 0x5e || - nextCodePoint === 0x5f - ) { - const terminatorIndex = findStringTerminatorIndex(input, index + 2); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); - if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { - append(sequence); - } - index = terminatorIndex; - continue; - } - - const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); - if (escapeSequenceEndIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - append(input.slice(index, escapeSequenceEndIndex)); - index = escapeSequenceEndIndex; - continue; - } - - if (codePoint === 0x9b) { - let cursor = index + 1; - while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { - const sequence = input.slice(index, cursor + 1); - const body = input.slice(index + 1, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } - index = cursor + 1; - break; - } - cursor += 1; - } - if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - continue; - } - - if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { - const terminatorIndex = findStringTerminatorIndex(input, index + 1); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); - if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { - append(sequence); - } - index = terminatorIndex; - continue; - } - - append(input[index] ?? ""); - index += 1; - } - - return { visibleText, pendingControlSequence: "" }; -} - -function legacySafeThreadId(threadId: string): string { - return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); -} - -function toSafeThreadId(threadId: string): string { - return `terminal_${Encoding.encodeBase64Url(threadId)}`; -} - -function toSafeTerminalId(terminalId: string): string { - return Encoding.encodeBase64Url(terminalId); -} - -function toSessionKey(threadId: string, terminalId: string): string { - return `${threadId}\u0000${terminalId}`; -} - -function shouldExcludeTerminalEnvKey(key: string): boolean { - const normalizedKey = key.toUpperCase(); - if (isManagedRuntimeEnvKey(normalizedKey)) { - return true; - } - if (normalizedKey.startsWith("VITE_")) { - return true; - } - return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); -} - -function createTerminalSpawnEnv( - baseEnv: NodeJS.ProcessEnv, - runtimeEnv?: Record | null, -): NodeJS.ProcessEnv { - const spawnEnv: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(baseEnv)) { - if (value === undefined) continue; - if (shouldExcludeTerminalEnvKey(key)) continue; - spawnEnv[key] = value; - } - if (runtimeEnv) { - for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; - } - } - return spawnEnv; -} - -function normalizedRuntimeEnv( - env: Readonly> | undefined, -): Record | null { - if (!env) return null; - const entries = Object.entries(env).filter( - (entry): entry is [string, string] => entry[1] !== undefined, - ); - if (entries.length === 0) return null; - return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); -} - -type TerminalAttachRuntimeInput = TerminalAttachInput & { - readonly projectId: ProjectId; -}; - -interface TerminalManagerOptions { - logsDir: string; - historyLineLimit?: number; - ptyAdapter: PtyAdapterShape; - shellResolver?: () => string; - env?: NodeJS.ProcessEnv; - subprocessInspector?: TerminalSubprocessInspector; - subprocessPollIntervalMs?: number; - processKillGraceMs?: number; - maxRetainedInactiveSessions?: number; - launchEnv: LaunchEnvShape; - registerTerminalProcesses?: (input: { - readonly threadId: string; - readonly terminalId: string; - readonly processIds: ReadonlyArray; - }) => Effect.Effect; - unregisterTerminal?: (input: { - readonly threadId: string; - readonly terminalId: string; - }) => Effect.Effect; -} - -const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { - const { terminalLogsDir } = yield* ServerConfig; - const ptyAdapter = yield* PtyAdapter; - const launchEnv = yield* LaunchEnv; - const portDiscovery = yield* PortScanner.PortDiscovery; - return yield* makeTerminalManagerWithOptions({ - logsDir: terminalLogsDir, - ptyAdapter, - launchEnv, - registerTerminalProcesses: portDiscovery.registerTerminalProcesses, - unregisterTerminal: portDiscovery.unregisterTerminal, - }); -}); - -export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( - function* (options: TerminalManagerOptions) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - const launchEnv = options.launchEnv; - - const toLaunchEnvInput = ( - input: Pick< - TerminalOpenInput | TerminalRestartInput | TerminalAttachInput, - "threadId" | "terminalId" | "projectId" | "worktreePath" | "env" - >, - ) => ({ - threadId: ThreadId.make(input.threadId), - terminalId: input.terminalId, - ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - ...(input.env !== undefined ? { extraEnv: input.env } : {}), - }); - - const applyLaunchEnv = (input: T) => - launchEnv.resolveForThread(toLaunchEnvInput(input)).pipe( - Effect.catchTags({ - LaunchEnvProjectLookupError: (error) => - Effect.fail( - new TerminalCwdError({ - cwd: error.projectId, - reason: error.reason === "notFound" ? "notFound" : "statFailed", - cause: error.cause, - }), - ), - LaunchEnvThreadLookupError: (error) => - Effect.fail( - new TerminalSessionLookupError({ - threadId: error.threadId, - terminalId: error.terminalId ?? "", - }), - ), - }), - Effect.map((resolved) => ({ - ...input, - ...(resolved.worktreePath !== undefined ? { worktreePath: resolved.worktreePath } : {}), - env: resolved.env, - })), - ); - - const resolveLaunchInput = (input: T) => - applyLaunchEnv(input).pipe( - Effect.catchTag("TerminalSessionLookupError", () => Effect.succeed(input)), - ); - - const applyLaunchEnvForAttach = (input: TerminalAttachInput) => - launchEnv.resolveForThread(toLaunchEnvInput(input)).pipe( - Effect.catchTags({ - LaunchEnvProjectLookupError: (error) => - Effect.fail( - new TerminalCwdError({ - cwd: error.projectId, - reason: error.reason === "notFound" ? "notFound" : "statFailed", - cause: error.cause, - }), - ), - LaunchEnvThreadLookupError: (error) => - Effect.fail( - new TerminalSessionLookupError({ - threadId: error.threadId, - terminalId: error.terminalId ?? "", - }), - ), - }), - Effect.map( - (resolved) => - ({ - ...input, - projectId: resolved.projectId, - ...(resolved.worktreePath !== undefined - ? { worktreePath: resolved.worktreePath } - : {}), - env: resolved.env, - }) satisfies TerminalAttachRuntimeInput, - ), - ); - - const resolveAttachLaunchInput = (input: TerminalAttachInput) => - applyLaunchEnvForAttach(input).pipe( - Effect.catchTag("TerminalSessionLookupError", () => Effect.succeed(input)), - ); - - const logsDir = options.logsDir; - const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const platform = yield* HostProcessPlatform; - // Terminals must inherit the user's full environment (minus the blocklist - // applied in createTerminalSpawnEnv) — an allowlist here silently strips - // things like PSModulePath, DISPLAY, proxies, and toolchain variables. - // `options.env` is the test seam. - const baseEnv = options.env ?? process.env; - const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); - const processRunner = yield* ProcessRunner.ProcessRunner; - const subprocessInspector = - options.subprocessInspector ?? - ((terminalPid) => - defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - )); - const subprocessPollIntervalMs = - options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; - const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; - const maxRetainedInactiveSessions = - options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; - const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); - const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); - - yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); - - const managerStateRef = yield* SynchronizedRef.make({ - sessions: new Map(), - killFibers: new Map(), - }); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); - const workerScope = yield* Scope.make("sequential"); - yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); - - const publishEvent = (event: TerminalEvent) => - Effect.gen(function* () { - for (const listener of terminalEventListeners) { - yield* listener(event).pipe(Effect.ignoreCause({ log: true })); - } - }); - - const historyPath = (threadId: string, terminalId: string) => { - const threadPart = toSafeThreadId(threadId); - if (terminalId === DEFAULT_TERMINAL_ID) { - return path.join(logsDir, `${threadPart}.log`); - } - return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); - }; - - const legacyHistoryPath = (threadId: string) => - path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); - - const toTerminalHistoryError = - (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => - (cause: unknown) => - new TerminalHistoryError({ - operation, - threadId, - terminalId, - cause, - }); - - const readManagerState = SynchronizedRef.get(managerStateRef); - - const modifyManagerState = ( - f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], - ) => SynchronizedRef.modify(managerStateRef, f); - - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - - const withThreadLock = ( - threadId: string, - effect: Effect.Effect, - ): Effect.Effect => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); - - const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( - process: PtyProcess | null, - ) { - if (!process) return; - const fiber: Option.Option> = yield* modifyManagerState< - Option.Option> - >((state) => { - const existing: Option.Option> = Option.fromNullishOr( - state.killFibers.get(process), - ); - if (Option.isNone(existing)) { - return [Option.none>(), state] as const; - } - const killFibers = new Map(state.killFibers); - killFibers.delete(process); - return [existing, { ...state, killFibers }] as const; - }); - if (Option.isSome(fiber)) { - yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore); - } - }); - - const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* ( - process: PtyProcess, - fiber: Fiber.Fiber, - ) { - yield* modifyManagerState((state) => { - const killFibers = new Map(state.killFibers); - killFibers.set(process, fiber); - return [undefined, { ...state, killFibers }] as const; - }); - }); - - const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* ( - process: PtyProcess, - threadId: string, - terminalId: string, - ) { - const terminated = yield* Effect.try({ - try: () => process.kill("SIGTERM"), - catch: (cause) => - new TerminalProcessSignalError({ - message: "Failed to send SIGTERM to terminal process.", - cause, - signal: "SIGTERM", - }), - }).pipe( - Effect.as(true), - Effect.catch((error) => - Effect.logWarning("failed to kill terminal process", { - threadId, - terminalId, - signal: "SIGTERM", - error: error.message, - }).pipe(Effect.as(false)), - ), - ); - if (!terminated) { - return; - } - - yield* Effect.sleep(processKillGraceMs); - - yield* Effect.try({ - try: () => process.kill("SIGKILL"), - catch: (cause) => - new TerminalProcessSignalError({ - message: "Failed to send SIGKILL to terminal process.", - cause, - signal: "SIGKILL", - }), - }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to force-kill terminal process", { - threadId, - terminalId, - signal: "SIGKILL", - error: error.message, - }), - ), - ); - }); - - const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( - process: PtyProcess, - threadId: string, - terminalId: string, - ) { - const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( - Effect.ensuring( - modifyManagerState((state) => { - if (!state.killFibers.has(process)) { - return [undefined, state] as const; - } - const killFibers = new Map(state.killFibers); - killFibers.delete(process); - return [undefined, { ...state, killFibers }] as const; - }), - ), - Effect.forkIn(workerScope), - ); - - yield* registerKillFiber(process, fiber); - }); - - const persistWorker = yield* makeKeyedCoalescingWorker< - string, - PersistHistoryRequest, - never, - never - >({ - merge: (current, next) => ({ - history: next.history, - immediate: current.immediate || next.immediate, - }), - process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) { - if (!request.immediate) { - yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS); - } - - const [threadId, terminalId] = sessionKey.split("\u0000"); - if (!threadId || !terminalId) { - return; - } - - yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( - Effect.catch((error) => - Effect.logWarning("failed to persist terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - }), - }); - - const queuePersist = Effect.fn("terminal.queuePersist")(function* ( - threadId: string, - terminalId: string, - history: string, - ) { - yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, - immediate: false, - }); - }); - - const flushPersist = Effect.fn("terminal.flushPersist")(function* ( - threadId: string, - terminalId: string, - ) { - yield* persistWorker.drainKey(toSessionKey(threadId, terminalId)); - }); - - const persistHistory = Effect.fn("terminal.persistHistory")(function* ( - threadId: string, - terminalId: string, - history: string, - ) { - yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, - immediate: true, - }); - yield* flushPersist(threadId, terminalId); - }); - - const readHistory = Effect.fn("terminal.readHistory")(function* ( - threadId: string, - terminalId: string, - ) { - const nextPath = historyPath(threadId, terminalId); - if ( - yield* fileSystem - .exists(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) - ) { - const raw = yield* fileSystem - .readFileString(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); - const capped = capHistory(raw, historyLineLimit); - if (capped !== raw) { - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); - } - return capped; - } - - if (terminalId !== DEFAULT_TERMINAL_ID) { - return ""; - } - - const legacyPath = legacyHistoryPath(threadId); - if ( - !(yield* fileSystem - .exists(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) - ) { - return ""; - } - - const raw = yield* fileSystem - .readFileString(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); - const capped = capHistory(raw, historyLineLimit); - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); - yield* fileSystem.remove(legacyPath, { force: true }).pipe( - Effect.catch((cleanupError) => - Effect.logWarning("failed to remove legacy terminal history", { - threadId, - error: cleanupError, - }), - ), - ); - return capped; - }); - - const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( - threadId: string, - terminalId: string, - ) { - yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - if (terminalId === DEFAULT_TERMINAL_ID) { - yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - } - }); - - const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( - threadId: string, - ) { - const threadPrefix = `${toSafeThreadId(threadId)}_`; - const entries = yield* fileSystem - .readDirectory(logsDir, { recursive: false }) - .pipe(Effect.orElseSucceed(() => [] as Array)); - yield* Effect.forEach( - entries.filter( - (name) => - name === `${toSafeThreadId(threadId)}.log` || - name === `${legacySafeThreadId(threadId)}.log` || - name.startsWith(threadPrefix), - ), - (name) => - fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal histories for thread", { - threadId, - error, - }), - ), - ), - { discard: true }, - ); - }); - - const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { - const stats = yield* fileSystem.stat(cwd).pipe( - Effect.mapError( - (cause) => - new TerminalCwdError({ - cwd, - reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", - cause, - }), - ), - ); - if (stats.type !== "Directory") { - return yield* new TerminalCwdError({ - cwd, - reason: "notDirectory", - }); - } - }); - - const getSession = Effect.fn("terminal.getSession")(function* ( - threadId: string, - terminalId: string, - ): Effect.fn.Return> { - return yield* Effect.map(readManagerState, (state) => - Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))), - ); - }); - - const requireSession = Effect.fn("terminal.requireSession")(function* ( - threadId: string, - terminalId: string, - ): Effect.fn.Return { - return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => - Option.match(session, { - onNone: () => - Effect.fail( - new TerminalSessionLookupError({ - threadId, - terminalId, - }), - ), - onSome: Effect.succeed, - }), - ); - }); - - const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) { - return yield* readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()].filter((session) => session.threadId === threadId), - ), - ); - }); - - const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")( - function* () { - yield* modifyManagerState((state) => { - const inactiveSessions = [...state.sessions.values()].filter( - (session) => session.status !== "running", - ); - if (inactiveSessions.length <= maxRetainedInactiveSessions) { - return [undefined, state] as const; - } - - inactiveSessions.sort( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || - left.threadId.localeCompare(right.threadId) || - left.terminalId.localeCompare(right.terminalId), - ); - - const sessions = new Map(state.sessions); - - const toEvict = inactiveSessions.length - maxRetainedInactiveSessions; - for (const session of inactiveSessions.slice(0, toEvict)) { - const key = toSessionKey(session.threadId, session.terminalId); - sessions.delete(key); - } - - return [undefined, { ...state, sessions }] as const; - }); - }, - ); - - const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( - session: TerminalSessionState, - expectedPid: number, - ) { - while (true) { - const action: DrainProcessEventAction = yield* Effect.sync(() => { - if (session.pid !== expectedPid || !session.process || session.status !== "running") { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - return { type: "idle" } as const; - } - - const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex]; - if (!nextEvent) { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - return { type: "idle" } as const; - } - - session.pendingProcessEventIndex += 1; - if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - } - - if (nextEvent.type === "output") { - const sanitized = sanitizeTerminalHistoryChunk( - session.pendingHistoryControlSequence, - nextEvent.data, - ); - session.pendingHistoryControlSequence = sanitized.pendingControlSequence; - if (sanitized.visibleText.length > 0) { - session.history = capHistory( - `${session.history}${sanitized.visibleText}`, - historyLineLimit, - ); - } - const eventStamp = advanceEventSequence(session); - - return { - type: "output", - threadId: session.threadId, - terminalId: session.terminalId, - sequence: eventStamp.sequence, - history: sanitized.visibleText.length > 0 ? session.history : null, - data: nextEvent.data, - } as const; - } - - const process = session.process; - cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.status = "exited"; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.exitCode = Number.isInteger(nextEvent.event.exitCode) - ? nextEvent.event.exitCode - : null; - session.exitSignal = Number.isInteger(nextEvent.event.signal) - ? nextEvent.event.signal - : null; - const eventStamp = advanceEventSequence(session); - - return { - type: "exit", - process, - threadId: session.threadId, - terminalId: session.terminalId, - sequence: eventStamp.sequence, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - } as const; - }); - - if (action.type === "idle") { - return; - } - - if (action.type === "output") { - if (action.history !== null) { - yield* queuePersist(action.threadId, action.terminalId, action.history); - } - - yield* publishEvent({ - type: "output", - threadId: action.threadId, - terminalId: action.terminalId, - sequence: action.sequence, - data: action.data, - }); - continue; - } - - yield* clearKillFiber(action.process); - yield* unregisterTerminal({ - threadId: action.threadId, - terminalId: action.terminalId, - }); - yield* publishEvent({ - type: "exited", - threadId: action.threadId, - terminalId: action.terminalId, - sequence: action.sequence, - exitCode: action.exitCode, - exitSignal: action.exitSignal, - }); - yield* evictInactiveSessionsIfNeeded(); - return; - } - }); - - const stopProcess = Effect.fn("terminal.stopProcess")(function* ( - session: TerminalSessionState, - ) { - const process = session.process; - if (!process) return; - - const updatedAt = yield* nowIso; - yield* modifyManagerState((state) => { - cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.status = "exited"; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = updatedAt; - return [undefined, state] as const; - }); - - yield* clearKillFiber(process); - yield* unregisterTerminal({ - threadId: session.threadId, - terminalId: session.terminalId, - }); - yield* startKillEscalation(process, session.threadId, session.terminalId); - yield* evictInactiveSessionsIfNeeded(); - }); - - const trySpawn = Effect.fn("terminal.trySpawn")(function* ( - shellCandidates: ReadonlyArray, - spawnEnv: NodeJS.ProcessEnv, - session: TerminalSessionState, - index = 0, - lastError: PtySpawnError | null = null, - ): Effect.fn.Return<{ process: PtyProcess; shellLabel: string }, PtySpawnError> { - if (index >= shellCandidates.length) { - const detail = lastError?.message ?? "Failed to spawn PTY process"; - const tried = - shellCandidates.length > 0 - ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` - : ""; - return yield* new PtySpawnError({ - adapter: "terminal-manager", - message: `${detail}.${tried}`.trim(), - ...(lastError ? { cause: lastError } : {}), - }); - } - - const candidate = shellCandidates[index]; - if (!candidate) { - return yield* ( - lastError ?? - new PtySpawnError({ - adapter: "terminal-manager", - message: "No shell candidate available for PTY spawn.", - }) - ); - } - - const attempt = yield* Effect.result( - options.ptyAdapter.spawn({ - shell: candidate.shell, - ...(candidate.args ? { args: candidate.args } : {}), - cwd: session.cwd, - cols: session.cols, - rows: session.rows, - env: spawnEnv, - }), - ); - - if (attempt._tag === "Success") { - return { - process: attempt.success, - shellLabel: formatShellCandidate(candidate), - }; - } - - const spawnError = attempt.failure; - if (!isRetryableShellSpawnError(spawnError)) { - return yield* spawnError; - } - - return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); - }); - - const startSession = Effect.fn("terminal.startSession")(function* ( - session: TerminalSessionState, - input: TerminalStartInput, - eventType: "started" | "restarted", - ) { - yield* stopProcess(session); - yield* Effect.annotateCurrentSpan({ - "terminal.thread_id": session.threadId, - "terminal.id": session.terminalId, - "terminal.event_type": eventType, - "terminal.cwd": input.cwd, - }); - - const startingAt = yield* nowIso; - yield* modifyManagerState((state) => { - session.status = "starting"; - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.cols = input.cols; - session.rows = input.rows; - session.exitCode = null; - session.exitSignal = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = startingAt; - return [undefined, state] as const; - }); - - let ptyProcess: PtyProcess | null = null; - let startedShell: string | null = null; - - const startResult = yield* Effect.result( - increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( - Effect.andThen( - Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); - ptyProcess = spawnResult.process; - startedShell = spawnResult.shellLabel; - - const processPid = ptyProcess.pid; - const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - - let eventStamp: ReturnType = { - updatedAt: session.updatedAt, - sequence: session.eventSequence, - }; - yield* modifyManagerState((state) => { - session.process = ptyProcess; - session.pid = processPid; - session.status = "running"; - session.unsubscribeData = unsubscribeData; - session.unsubscribeExit = unsubscribeExit; - eventStamp = advanceEventSequence(session); - return [undefined, state] as const; - }); - - yield* publishEvent({ - type: eventType, - threadId: session.threadId, - terminalId: session.terminalId, - sequence: eventStamp.sequence, - snapshot: snapshot(session), - }); - }), - ), - ), - ); - - if (startResult._tag === "Success") { - return; - } - - { - const error = startResult.failure; - if (ptyProcess) { - yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); - } - - yield* modifyManagerState((state) => { - session.status = "error"; - session.pid = null; - session.process = null; - session.unsubscribeData = null; - session.unsubscribeExit = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - advanceEventSequence(session); - return [undefined, state] as const; - }); - yield* unregisterTerminal({ - threadId: session.threadId, - terminalId: session.terminalId, - }); - - yield* evictInactiveSessionsIfNeeded(); - - const message = error.message; - yield* publishEvent({ - type: "error", - threadId: session.threadId, - terminalId: session.terminalId, - sequence: session.eventSequence, - message, - }); - yield* Effect.logError("failed to start terminal", { - threadId: session.threadId, - terminalId: session.terminalId, - error: message, - ...(startedShell ? { shell: startedShell } : {}), - }); - } - }); - - const closeSession = Effect.fn("terminal.closeSession")(function* ( - threadId: string, - terminalId: string, - deleteHistoryOnClose: boolean, - ) { - const key = toSessionKey(threadId, terminalId); - const session = yield* getSession(threadId, terminalId); - const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; - - if (Option.isSome(session)) { - yield* stopProcess(session.value); - yield* unregisterTerminal({ threadId, terminalId }); - yield* persistHistory(threadId, terminalId, session.value.history); - } - - yield* flushPersist(threadId, terminalId); - - const removed = yield* modifyManagerState((state) => { - if (!state.sessions.has(key)) { - return [false, state] as const; - } - const sessions = new Map(state.sessions); - sessions.delete(key); - return [true, { ...state, sessions }] as const; - }); - - if (removed) { - yield* publishEvent({ - type: "closed", - threadId, - terminalId, - sequence: closedEventSequence, - }); - } - - if (deleteHistoryOnClose) { - yield* deleteHistory(threadId, terminalId); - } - }); - - const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { - const state = yield* readManagerState; - const runningSessions = [...state.sessions.values()].filter( - (session): session is TerminalSessionState & { pid: number } => - session.status === "running" && Number.isInteger(session.pid), - ); - - if (runningSessions.length === 0) { - return; - } - - const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( - session: TerminalSessionState & { pid: number }, - ) { - const terminalPid = session.pid; - const inspectResult = yield* subprocessInspector(terminalPid).pipe( - Effect.map(Option.some), - Effect.catch((reason) => - Effect.logWarning("failed to check terminal subprocess activity", { - threadId: session.threadId, - terminalId: session.terminalId, - terminalPid, - reason, - }).pipe(Effect.as(Option.none())), - ), - ); - - if (Option.isNone(inspectResult)) { - return; - } - - const next = inspectResult.value; - yield* registerTerminalProcesses({ - threadId: session.threadId, - terminalId: session.terminalId, - processIds: next.processIds, - }); - const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; - const event = yield* modifyManagerState((state) => { - const liveSession: Option.Option = Option.fromNullishOr( - state.sessions.get(toSessionKey(session.threadId, session.terminalId)), - ); - if ( - Option.isNone(liveSession) || - liveSession.value.status !== "running" || - liveSession.value.pid !== terminalPid || - (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && - liveSession.value.childCommandLabel === nextChildLabel) - ) { - return [Option.none(), state] as const; - } - - liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; - liveSession.value.childCommandLabel = nextChildLabel; - const eventStamp = advanceEventSequence(liveSession.value); - - return [ - Option.some({ - type: "activity" as const, - threadId: liveSession.value.threadId, - terminalId: liveSession.value.terminalId, - sequence: eventStamp.sequence, - hasRunningSubprocess: next.hasRunningSubprocess, - label: terminalWireLabel(liveSession.value), - }), - state, - ] as const; - }); - - if (Option.isSome(event)) { - yield* publishEvent(event.value); - } - }); - - yield* Effect.forEach(runningSessions, checkSubprocessActivity, { - concurrency: "unbounded", - discard: true, - }); - }); - - const hasRunningSessions = readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()].some((session) => session.status === "running"), - ), - ); - - yield* Effect.forever( - hasRunningSessions.pipe( - Effect.flatMap((active) => - active - ? pollSubprocessActivity().pipe( - Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), - ) - : Effect.sleep(subprocessPollIntervalMs), - ), - ), - ).pipe(Effect.forkIn(workerScope)); - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const sessions = yield* modifyManagerState( - (state) => - [ - [...state.sessions.values()], - { - ...state, - sessions: new Map(), - }, - ] as const, - ); - - const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( - session: TerminalSessionState, - ) { - cleanupProcessHandles(session); - if (!session.process) return; - yield* clearKillFiber(session.process); - yield* runKillEscalation(session.process, session.threadId, session.terminalId); - }); - - yield* Effect.forEach(sessions, cleanupSession, { - concurrency: "unbounded", - discard: true, - }); - }).pipe(Effect.ignoreCause({ log: true })), - ); - - const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - const nextRuntimeEnv = normalizedRuntimeEnv(input.env); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existing = yield* getSession(input.threadId, terminalId); - if (Option.isNone(existing)) { - yield* flushPersist(input.threadId, terminalId); - const history = yield* readHistory(input.threadId, terminalId); - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - const session: TerminalSessionState = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history, - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: nextRuntimeEnv, - }; - - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - - yield* evictInactiveSessionsIfNeeded(); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "started", - ); - return snapshot(session); - } - - const liveSession = existing.value; - const currentRuntimeEnv = liveSession.runtimeEnv; - const targetCols = input.cols ?? liveSession.cols; - const targetRows = input.rows ?? liveSession.rows; - const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); - const nextWorktreePath = - input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; - const launchContextChanged = - liveSession.cwd !== input.cwd || - runtimeEnvChanged || - liveSession.worktreePath !== nextWorktreePath; - - if (launchContextChanged) { - yield* stopProcess(liveSession); - liveSession.cwd = input.cwd; - liveSession.worktreePath = nextWorktreePath; - liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.history = ""; - liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; - yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); - } else if (liveSession.status === "exited" || liveSession.status === "error") { - liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.worktreePath = nextWorktreePath; - liveSession.history = ""; - liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; - yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); - } - - if (!liveSession.process) { - yield* startSession( - liveSession, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: liveSession.worktreePath, - cols: targetCols, - rows: targetRows, - ...(input.env ? { env: input.env } : {}), - }, - "started", - ); - return snapshot(liveSession); - } - - if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { - liveSession.cols = targetCols; - liveSession.rows = targetRows; - liveSession.updatedAt = yield* nowIso; - liveSession.process.resize(targetCols, targetRows); - } - - return snapshot(liveSession); - }); - - const open: TerminalManagerShape["open"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const resolvedInput = yield* resolveLaunchInput(input); - return yield* openLocked(resolvedInput); - }), - ); - - const openOrAttachForStream = (input: TerminalAttachInput) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const terminalId = input.terminalId; - const existing = yield* getSession(input.threadId, terminalId); - - if (Option.isNone(existing)) { - if (!input.cwd) { - return yield* new TerminalSessionLookupError({ - threadId: input.threadId, - terminalId, - }); - } - const resolvedInput = yield* resolveAttachLaunchInput(input); - return yield* openLocked({ - ...resolvedInput, - terminalId, - cwd: input.cwd, - }); - } - - const session = existing.value; - const targetCols = input.cols ?? session.cols; - const targetRows = input.rows ?? session.rows; - - if (!session.process && input.cwd && input.restartIfNotRunning === true) { - const resolvedInput = yield* resolveAttachLaunchInput(input); - return yield* openLocked({ - ...resolvedInput, - terminalId, - cwd: input.cwd, - }); - } - - if ( - session.process && - session.status === "running" && - (session.cols !== targetCols || session.rows !== targetRows) - ) { - session.cols = targetCols; - session.rows = targetRows; - session.updatedAt = yield* nowIso; - yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); - } - - return snapshot(session); - }), - ); - - const readAllTerminalMetadata = () => - readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()] - .map(summary) - .sort( - (left, right) => - right.updatedAt.localeCompare(left.updatedAt) || - left.threadId.localeCompare(right.threadId) || - left.terminalId.localeCompare(right.terminalId), - ), - ), - ); - - const readTerminalMetadata = (input: { - readonly threadId: string; - readonly terminalId: string; - }) => - getSession(input.threadId, input.terminalId).pipe( - Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), - ); - - const subscribe: TerminalManagerShape["subscribe"] = (listener) => - Effect.sync(() => { - terminalEventListeners.add(listener); - return () => { - terminalEventListeners.delete(listener); - }; - }); - - const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => { - let unsubscribe: (() => void) | null = null; - - return Effect.gen(function* () { - const bufferedEvents: TerminalEvent[] = []; - let deliverLive = false; - - unsubscribe = yield* subscribe((event) => { - if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { - return Effect.void; - } - - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; - } - - const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent) : Effect.void; - }); - - const initialSnapshot = yield* openOrAttachForStream(input); - - yield* listener({ - type: "snapshot", - snapshot: initialSnapshot, - }); - - for (const event of bufferedEvents) { - if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { - continue; - } - - const attachEvent = terminalEventToAttachEvent(event); - if (attachEvent) { - yield* listener(attachEvent); - } - } - - deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; - }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), - ), - ), - ); - }; - - const metadataEventFromTerminalEvent = ( - event: TerminalEvent, - ): Effect.Effect => { - if (!shouldPublishTerminalMetadataEvent(event)) { - return Effect.succeed(null); - } - - if (event.type === "closed") { - return Effect.succeed({ - type: "remove" as const, - threadId: event.threadId, - terminalId: event.terminalId, - }); - } - - return readTerminalMetadata({ - threadId: event.threadId, - terminalId: event.terminalId, - }).pipe( - Effect.map((terminal) => - terminal - ? { - type: "upsert" as const, - terminal, - } - : null, - ), - ); - }; - - const offerMetadataEvent = ( - listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, - event: TerminalEvent, - ) => - metadataEventFromTerminalEvent(event).pipe( - Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), - ); - - const subscribeMetadata: TerminalManagerShape["subscribeMetadata"] = (listener) => { - let unsubscribe: (() => void) | null = null; - - return Effect.gen(function* () { - const bufferedEvents: TerminalEvent[] = []; - let deliverLive = false; - - unsubscribe = yield* subscribe((event) => { - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; - } - - return offerMetadataEvent(listener, event); - }); - - const terminals = yield* readAllTerminalMetadata(); - yield* listener({ - type: "snapshot", - terminals, - }); - - for (const event of bufferedEvents) { - yield* offerMetadataEvent(listener, event); - } - - deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; - }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), - ), - ), - ); - }; - - const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { - const terminalId = input.terminalId; - const session = yield* requireSession(input.threadId, terminalId); - const process = session.process; - if (!process || session.status !== "running") { - if (session.status === "exited") return; - return yield* new TerminalNotRunningError({ - threadId: input.threadId, - terminalId, - }); - } - yield* Effect.sync(() => process.write(input.data)); - }); - - const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) { - const terminalId = input.terminalId; - const session = yield* requireSession(input.threadId, terminalId); - const process = session.process; - if (!process || session.status !== "running") { - return yield* new TerminalNotRunningError({ - threadId: input.threadId, - terminalId, - }); - } - session.cols = input.cols; - session.rows = input.rows; - session.updatedAt = yield* nowIso; - yield* Effect.sync(() => process.resize(input.cols, input.rows)); - }); - - const clear: TerminalManagerShape["clear"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const terminalId = input.terminalId; - const session = yield* requireSession(input.threadId, terminalId); - session.history = ""; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - const eventStamp = advanceEventSequence(session); - yield* persistHistory(input.threadId, terminalId, session.history); - yield* publishEvent({ - type: "cleared", - threadId: input.threadId, - terminalId, - sequence: eventStamp.sequence, - }); - }), - ); - - const restart: TerminalManagerShape["restart"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const resolvedInput = yield* resolveLaunchInput(input); - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = resolvedInput.terminalId; - yield* assertValidCwd(resolvedInput.cwd); - const nextRuntimeEnv = normalizedRuntimeEnv(resolvedInput.env); - - const sessionKey = toSessionKey(resolvedInput.threadId, terminalId); - const existingSession = yield* getSession(resolvedInput.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = resolvedInput.cols ?? DEFAULT_OPEN_COLS; - const rows = resolvedInput.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: resolvedInput.threadId, - terminalId, - cwd: resolvedInput.cwd, - worktreePath: resolvedInput.worktreePath ?? null, - status: "starting", - pid: null, - history: "", - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: nextRuntimeEnv, - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = resolvedInput.cwd; - session.worktreePath = resolvedInput.worktreePath ?? null; - session.runtimeEnv = nextRuntimeEnv; - } - - const cols = resolvedInput.cols ?? session.cols; - const rows = resolvedInput.rows ?? session.rows; - - session.history = ""; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(resolvedInput.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: resolvedInput.threadId, - terminalId, - cwd: resolvedInput.cwd, - ...(resolvedInput.worktreePath !== undefined - ? { worktreePath: resolvedInput.worktreePath } - : {}), - cols, - rows, - ...(resolvedInput.env ? { env: resolvedInput.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), - ); - - const close: TerminalManagerShape["close"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - if (input.terminalId) { - yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); - return; - } - - const threadSessions = yield* sessionsForThread(input.threadId); - yield* Effect.forEach( - threadSessions, - (session) => closeSession(input.threadId, session.terminalId, false), - { discard: true }, - ); - - if (input.deleteHistory) { - yield* deleteAllHistoryForThread(input.threadId); - } - }), - ); - - return { - open, - attachStream, - write, - resize, - clear, - restart, - close, - subscribe, - subscribeMetadata, - } satisfies TerminalManagerShape; - }, -); - -export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe( - Layer.provide(ProcessRunner.layer), -); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts similarity index 89% rename from apps/server/src/terminal/Layers/Manager.test.ts rename to apps/server/src/terminal/Manager.test.ts index a4b56c05de8..3a1cabc4a27 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, - ProjectId, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, @@ -24,32 +23,26 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; -import { TestClock } from "effect/testing"; +import * as TestClock from "effect/testing/TestClock"; import { expect } from "vite-plus/test"; -import * as ProcessRunner from "../../processRunner.ts"; -import type { TerminalManagerShape } from "../Services/Manager.ts"; -import { - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, - type PtySpawnInput, - PtySpawnError, -} from "../Services/PTY.ts"; -import { makeTerminalManagerWithOptions } from "./Manager.ts"; -import { launchEnvTestStub } from "../../launchEnv/Layers/LaunchEnvTest.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as TerminalManager from "./Manager.ts"; +import * as PtyAdapter from "./PtyAdapter.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ readonly message: string; }> {} -class FakePtyProcess implements PtyProcess { +class FakePtyProcess implements PtyAdapter.PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; readonly pid: number; + writeFailure: unknown | undefined; + resizeFailure: unknown | undefined; private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); killed = false; constructor(pid: number) { @@ -57,10 +50,16 @@ class FakePtyProcess implements PtyProcess { } write(data: string): void { + if (this.writeFailure !== undefined) { + throw this.writeFailure; + } this.writes.push(data); } resize(cols: number, rows: number): void { + if (this.resizeFailure !== undefined) { + throw this.resizeFailure; + } this.resizeCalls.push({ cols, rows }); } @@ -76,7 +75,7 @@ class FakePtyProcess implements PtyProcess { }; } - onExit(callback: (event: PtyExitEvent) => void): () => void { + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); @@ -89,15 +88,15 @@ class FakePtyProcess implements PtyProcess { } } - emitExit(event: PtyExitEvent): void { + emitExit(event: PtyAdapter.PtyExitEvent): void { for (const listener of this.exitListeners) { listener(event); } } } -class FakePtyAdapter implements PtyAdapterShape { - readonly spawnInputs: PtySpawnInput[] = []; +class FakePtyAdapter { + readonly spawnInputs: PtyAdapter.PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; private readonly mode: "sync" | "async"; @@ -107,14 +106,16 @@ class FakePtyAdapter implements PtyAdapterShape { this.mode = mode; } - spawn(input: PtySpawnInput): Effect.Effect { + spawn( + input: PtyAdapter.PtySpawnInput, + ): Effect.Effect { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { return Effect.fail( - new PtySpawnError({ + new PtyAdapter.PtySpawnError({ adapter: "fake", - message: "Failed to spawn PTY process", + shell: input.shell, cause: failure, }), ); @@ -125,9 +126,9 @@ class FakePtyAdapter implements PtyAdapterShape { return Effect.tryPromise({ try: async () => process, catch: (cause) => - new PtySpawnError({ + new PtyAdapter.PtySpawnError({ adapter: "fake", - message: "Failed to spawn PTY process", + shell: input.shell, cause, }), }); @@ -218,7 +219,7 @@ interface ManagerFixture { readonly baseDir: string; readonly logsDir: string; readonly ptyAdapter: FakePtyAdapter; - readonly manager: TerminalManagerShape; + readonly manager: TerminalManager.TerminalManager["Service"]; readonly getEvents: Effect.Effect>; } @@ -237,14 +238,10 @@ const createManager = ( const logsDir = join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); - const manager = yield* makeTerminalManagerWithOptions({ + const manager = yield* TerminalManager.makeWithOptions({ logsDir, historyLineLimit, ptyAdapter, - launchEnv: launchEnvTestStub({ - t3Home: baseDir, - projectId: ProjectId.make("project-1"), - }), ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined @@ -325,6 +322,31 @@ it.layer( }), ); + it.effect("keeps attach streams live when a terminal id is closed and reopened", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* manager.close({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + deleteHistory: true, + }); + yield* manager.open(openInput()); + + const events = yield* Ref.get(attachEvents); + expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); + expect( + events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), + ).toEqual(["running", "running"]); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + }), + ); + it.effect("attaches to exited sessions without restarting them", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(); @@ -421,6 +443,39 @@ it.layer( fs.writeFileString(filePath, contents), ); + it.effect("reports a missing cwd without an artificial cause", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "missing-cwd"); + const error = yield* Effect.flip(manager.open(openInput({ cwd }))); + + expect(error).toMatchObject({ + _tag: "TerminalCwdNotFoundError", + cwd, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("reports a cwd that is not a directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "cwd-file"); + yield* writeFileString(cwd, "not a directory"); + const error = yield* Effect.flip(manager.open(openInput({ cwd }))); + + expect(error).toMatchObject({ + _tag: "TerminalCwdNotDirectoryError", + cwd, + }); + expect("cause" in error).toBe(false); + }), + ); + it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; @@ -438,9 +493,11 @@ it.layer( ); expect(error).toMatchObject({ - _tag: "TerminalCwdError", + _tag: "TerminalCwdStatError", cwd: blockedCwd, - reason: "statFailed", + cause: { + _tag: "PlatformError", + }, }); }), ); @@ -484,6 +541,84 @@ it.layer( }), ); + it.effect("preserves structured context and causes for PTY I/O failures", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const writeCause = new Error("PTY input handle is unavailable"); + process.writeFailure = writeCause; + const writeError = yield* Effect.flip( + manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "secret input that must not be attached to the error", + }), + ); + + expect(writeError).toMatchObject({ + _tag: "TerminalWriteError", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + terminalPid: process.pid, + }); + expect(writeError.cause).toBe(writeCause); + expect(writeError).not.toHaveProperty("data"); + + const resizeCause = new Error("PTY resize handle is unavailable"); + process.resizeFailure = resizeCause; + const resizeError = yield* Effect.flip( + manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 132, + rows: 40, + }), + ); + + expect(resizeError).toMatchObject({ + _tag: "TerminalResizeError", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + terminalPid: process.pid, + cols: 132, + rows: 40, + }); + expect(resizeError.cause).toBe(resizeCause); + + process.resizeFailure = undefined; + yield* manager.open(openInput({ cols: 132, rows: 40 })); + expect(process.resizeCalls).toEqual([{ cols: 132, rows: 40 }]); + }), + ); + + it.effect("ignores delayed resize requests after a terminal closes", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + yield* manager.close({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + deleteHistory: true, + }); + yield* manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 120, + rows: 30, + }); + + expect(process.resizeCalls).toEqual([]); + }), + ); + it.effect("resizes running terminal on open when a different size is requested", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts new file mode 100644 index 00000000000..6347fdfc64d --- /dev/null +++ b/apps/server/src/terminal/Manager.ts @@ -0,0 +1,2623 @@ +/** + * TerminalManager - Terminal session orchestration service interface. + * + * Owns terminal lifecycle operations, output fanout, and session state + * transitions for thread-scoped terminals. + * + * @module TerminalManager + */ +import { + DEFAULT_TERMINAL_ID, + TerminalCwdError, + TerminalCwdNotDirectoryError, + TerminalCwdNotFoundError, + TerminalCwdStatError, + TerminalError, + TerminalHistoryError, + TerminalNotRunningError, + TerminalResizeError, + TerminalSessionLookupError, + TerminalWriteError, + type TerminalAttachInput, + type TerminalAttachStreamEvent, + type TerminalClearInput, + type TerminalCloseInput, + type TerminalEvent, + type TerminalMetadataStreamEvent, + type TerminalOpenInput, + type TerminalResizeInput, + type TerminalRestartInput, + type TerminalSessionSnapshot, + type TerminalSessionStatus, + type TerminalSummary, + type TerminalWriteInput, +} from "@t3tools/contracts"; +import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import * as DateTime from "effect/DateTime"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as ServerConfig from "../config.ts"; +import { + increment, + terminalRestartsTotal, + terminalSessionsTotal, +} from "../observability/Metrics.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; +import * as PtyAdapter from "./PtyAdapter.ts"; + +export { + TerminalCwdError, + TerminalCwdNotDirectoryError, + TerminalCwdNotFoundError, + TerminalCwdStatError, + TerminalError, + TerminalHistoryError, + TerminalNotRunningError, + TerminalResizeError, + TerminalSessionLookupError, + TerminalWriteError, +}; + +const DEFAULT_HISTORY_LINE_LIMIT = 5_000; +const DEFAULT_PERSIST_DEBOUNCE_MS = 40; +const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; +const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; +const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; +const DEFAULT_OPEN_COLS = 120; +const DEFAULT_OPEN_ROWS = 30; +const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const MAX_TERMINAL_LABEL_LENGTH = 128; + +class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( + "TerminalSubprocessCheckError", + { + cause: Schema.optional(Schema.Defect()), + terminalPid: Schema.Number, + command: Schema.Literals(["powershell", "pgrep", "ps"]), + }, +) { + override get message(): string { + return `Failed to inspect terminal subprocesses for PID ${this.terminalPid} with ${this.command}`; + } +} + +class TerminalProcessSignalError extends Schema.TaggedErrorClass()( + "TerminalProcessSignalError", + { + cause: Schema.optional(Schema.Defect()), + signal: Schema.Literals(["SIGTERM", "SIGKILL"]), + terminalPid: Schema.Number, + }, +) { + override get message(): string { + return `Failed to send ${this.signal} to terminal process ${this.terminalPid}`; + } +} + +/** + * TerminalManager - Service tag for terminal session orchestration. + */ +export class TerminalManager extends Context.Service< + TerminalManager, + { + /** + * Open or attach to a terminal session. + * + * Reuses an existing session for the same thread/terminal id and restores + * persisted history on first open. + */ + readonly open: ( + input: TerminalOpenInput, + ) => Effect.Effect; + + /** + * Attach to a terminal and stream its initial snapshot followed by live events. + * + * Returns an unsubscribe function. + */ + readonly attachStream: ( + input: TerminalAttachInput, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, TerminalError>; + + /** + * Write input bytes to a terminal session. + */ + readonly write: (input: TerminalWriteInput) => Effect.Effect; + + /** + * Resize the PTY backing a terminal session. + */ + readonly resize: (input: TerminalResizeInput) => Effect.Effect; + + /** + * Clear terminal output history. + */ + readonly clear: (input: TerminalClearInput) => Effect.Effect; + + /** + * Restart a terminal session in place. + * + * Always resets history before spawning the new process. + */ + readonly restart: ( + input: TerminalRestartInput, + ) => Effect.Effect; + + /** + * Close an active terminal session. + * + * When `terminalId` is omitted, closes all sessions for the thread. + */ + readonly close: (input: TerminalCloseInput) => Effect.Effect; + + /** + * Subscribe to terminal runtime events with a direct callback. + * + * Returns an unsubscribe function. + */ + readonly subscribe: ( + listener: (event: TerminalEvent) => Effect.Effect, + ) => Effect.Effect<() => void>; + + /** + * Subscribe to lightweight terminal metadata with an initial full snapshot. + * + * Returns an unsubscribe function. + */ + readonly subscribeMetadata: ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void>; + } +>()("t3/terminal/Manager/TerminalManager") {} + +interface TerminalSubprocessInspectResult { + readonly hasRunningSubprocess: boolean; + readonly childCommand: string | null; + readonly processIds: ReadonlyArray; +} + +interface TerminalSubprocessInspector { + ( + terminalPid: number, + ): Effect.Effect; +} + +const resizePtyProcess = ( + session: TerminalSessionState, + process: PtyAdapter.PtyProcess, + cols: number, + rows: number, +) => + Effect.try({ + try: () => process.resize(cols, rows), + catch: (cause) => + new TerminalResizeError({ + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid: process.pid, + cols, + rows, + cause, + }), + }); + +export interface ShellCandidate { + shell: string; + args?: string[]; +} + +export interface TerminalStartInput extends TerminalOpenInput { + cols: number; + rows: number; +} + +export interface TerminalSessionState { + threadId: string; + terminalId: string; + cwd: string; + worktreePath: string | null; + status: TerminalSessionStatus; + pid: number | null; + history: string; + pendingHistoryControlSequence: string; + pendingProcessEvents: Array; + pendingProcessEventIndex: number; + processEventDrainRunning: boolean; + exitCode: number | null; + exitSignal: number | null; + updatedAt: string; + eventSequence: number; + cols: number; + rows: number; + process: PtyAdapter.PtyProcess | null; + unsubscribeData: (() => void) | null; + unsubscribeExit: (() => void) | null; + hasRunningSubprocess: boolean; + /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ + childCommandLabel: string | null; + runtimeEnv: Record | null; +} + +interface PersistHistoryRequest { + history: string; + immediate: boolean; +} + +type PendingProcessEvent = + | { type: "output"; data: string } + | { type: "exit"; event: PtyAdapter.PtyExitEvent }; + +type DrainProcessEventAction = + | { type: "idle" } + | { + type: "output"; + threadId: string; + terminalId: string; + sequence: number; + history: string | null; + data: string; + } + | { + type: "exit"; + process: PtyAdapter.PtyProcess | null; + threadId: string; + terminalId: string; + sequence: number; + exitCode: number | null; + exitSignal: number | null; + }; + +interface TerminalManagerState { + sessions: Map; + killFibers: Map>; +} + +function truncateTerminalWireLabel(value: string): string { + if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; + return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); +} + +function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { + let trimmed = raw.trim(); + if (trimmed.length === 0) return null; + if ( + (trimmed.startsWith("[") && trimmed.endsWith("]")) || + (trimmed.startsWith("(") && trimmed.endsWith(")")) + ) { + trimmed = trimmed.slice(1, -1).trim(); + } + const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); + if (firstToken.length === 0) return null; + const separators = platform === "win32" ? /[\\/]/ : /\//; + const base = firstToken.split(separators).at(-1) ?? firstToken; + const withoutExe = + platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; + return withoutExe.length > 0 ? withoutExe : null; +} + +function terminalWireLabel(session: TerminalSessionState): string { + if (session.hasRunningSubprocess && session.childCommandLabel) { + const trimmed = session.childCommandLabel.trim(); + if (trimmed.length > 0) { + return truncateTerminalWireLabel(trimmed); + } + } + return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); +} + +function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { + return { + threadId: session.threadId, + terminalId: session.terminalId, + cwd: session.cwd, + worktreePath: session.worktreePath, + status: session.status, + pid: session.pid, + history: session.history, + exitCode: session.exitCode, + exitSignal: session.exitSignal, + label: terminalWireLabel(session), + updatedAt: session.updatedAt, + sequence: session.eventSequence, + }; +} + +function summary(session: TerminalSessionState): TerminalSummary { + return { + threadId: session.threadId, + terminalId: session.terminalId, + cwd: session.cwd, + worktreePath: session.worktreePath, + status: session.status, + pid: session.pid, + exitCode: session.exitCode, + exitSignal: session.exitSignal, + hasRunningSubprocess: session.hasRunningSubprocess, + label: terminalWireLabel(session), + updatedAt: session.updatedAt, + }; +} + +function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { + switch (event.type) { + case "started": + case "restarted": + case "exited": + case "closed": + case "error": + case "activity": + return true; + case "output": + case "cleared": + return false; + } +} + +function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null { + switch (event.type) { + case "started": + return { + type: "snapshot", + snapshot: event.snapshot, + }; + case "output": + case "exited": + case "closed": + case "error": + case "cleared": + case "restarted": + case "activity": + return event; + } +} + +function isDuplicateAttachSnapshotEvent( + event: TerminalEvent, + initialSnapshot: TerminalSessionSnapshot, +) { + return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" + ? event.sequence <= initialSnapshot.sequence + : event.type === "started" && + event.snapshot.threadId === initialSnapshot.threadId && + event.snapshot.terminalId === initialSnapshot.terminalId && + event.snapshot.updatedAt <= initialSnapshot.updatedAt; +} + +function advanceEventSequence(session: TerminalSessionState): { + readonly updatedAt: string; + readonly sequence: number; +} { + const updatedAt = DateTime.formatIso(DateTime.nowUnsafe()); + session.eventSequence += 1; + session.updatedAt = updatedAt; + return { updatedAt, sequence: session.eventSequence }; +} + +function cleanupProcessHandles(session: TerminalSessionState): void { + session.unsubscribeData?.(); + session.unsubscribeData = null; + session.unsubscribeExit?.(); + session.unsubscribeExit = null; +} + +function enqueueProcessEvent( + session: TerminalSessionState, + expectedPid: number, + event: PendingProcessEvent, +): boolean { + if (!session.process || session.status !== "running" || session.pid !== expectedPid) { + return false; + } + + session.pendingProcessEvents.push(event); + if (session.processEventDrainRunning) { + return false; + } + + session.processEventDrainRunning = true; + return true; +} + +function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { + if (platform === "win32") { + return "pwsh.exe"; + } + return env.SHELL ?? "bash"; +} + +function normalizeShellCommand( + value: string | undefined, + platform: NodeJS.Platform, +): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + if (platform === "win32") { + return trimmed; + } + + const firstToken = trimmed.split(/\s+/g)[0]?.trim(); + if (!firstToken) return null; + return firstToken.replace(/^['"]|['"]$/g, ""); +} + +function basenameForPlatform(command: string, platform: NodeJS.Platform): string { + const normalized = + platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); + const parts = normalized + .split(platform === "win32" ? /\\+/ : /\/+/) + .filter((part) => part.length > 0); + return parts.at(-1) ?? normalized; +} + +function joinWindowsPath(...parts: ReadonlyArray): string { + return parts + .map((part, index) => { + if (index === 0) return part.replace(/[\\/]+$/g, ""); + return part.replace(/^[\\/]+|[\\/]+$/g, ""); + }) + .filter((part) => part.length > 0) + .join("\\"); +} + +function shellCandidateFromCommand( + command: string | null, + platform: NodeJS.Platform, +): ShellCandidate | null { + if (!command || command.length === 0) return null; + const shellName = basenameForPlatform(command, platform).toLowerCase(); + if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { + return { shell: command, args: ["-NoLogo"] }; + } + if (platform !== "win32" && shellName === "zsh") { + return { shell: command, args: ["-o", "nopromptsp"] }; + } + return { shell: command }; +} + +function windowsSystemRoot(env: NodeJS.ProcessEnv): string { + return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; +} + +function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { + return joinWindowsPath( + windowsSystemRoot(env), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +function windowsCmdPath(env: NodeJS.ProcessEnv): string { + return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); +} + +function formatShellCandidate(candidate: ShellCandidate): string { + if (!candidate.args || candidate.args.length === 0) return candidate.shell; + return `${candidate.shell} ${candidate.args.join(" ")}`; +} + +function uniqueShellCandidates(candidates: Array): ShellCandidate[] { + const seen = new Set(); + const ordered: ShellCandidate[] = []; + for (const candidate of candidates) { + if (!candidate) continue; + const key = formatShellCandidate(candidate); + if (seen.has(key)) continue; + seen.add(key); + ordered.push(candidate); + } + return ordered; +} + +function resolveShellCandidates( + shellResolver: () => string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): ShellCandidate[] { + const requested = shellCandidateFromCommand( + normalizeShellCommand(shellResolver(), platform), + platform, + ); + + if (platform === "win32") { + return uniqueShellCandidates([ + requested, + shellCandidateFromCommand("pwsh.exe", platform), + shellCandidateFromCommand(windowsPowerShellPath(env), platform), + shellCandidateFromCommand("powershell.exe", platform), + shellCandidateFromCommand(env.ComSpec ?? null, platform), + shellCandidateFromCommand(windowsCmdPath(env), platform), + shellCandidateFromCommand("cmd.exe", platform), + ]); + } + + return uniqueShellCandidates([ + requested, + shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), + shellCandidateFromCommand("/bin/zsh", platform), + shellCandidateFromCommand("/bin/bash", platform), + shellCandidateFromCommand("/bin/sh", platform), + shellCandidateFromCommand("zsh", platform), + shellCandidateFromCommand("bash", platform), + shellCandidateFromCommand("sh", platform), + ]); +} + +function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean { + const queue: unknown[] = [error]; + const seen = new Set(); + const messages: string[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current)) { + continue; + } + seen.add(current); + + if (typeof current === "string") { + messages.push(current); + continue; + } + + if (current instanceof Error) { + messages.push(current.message); + if (current.cause) { + queue.push(current.cause); + } + continue; + } + + if (typeof current === "object") { + const value = current as { message?: unknown; cause?: unknown }; + if (typeof value.message === "string") { + messages.push(value.message); + } + if (value.cause) { + queue.push(value.cause); + } + } + } + + const message = messages.join(" ").toLowerCase(); + return ( + message.includes("posix_spawnp failed") || + message.includes("enoent") || + message.includes("not found") || + message.includes("file not found") || + message.includes("no such file") + ); +} + +function parseFirstChildPidFromPgrep(stdout: string): number | null { + for (const line of stdout.split(/\r?\n/g)) { + const n = Number.parseInt(line.trim(), 10); + if (Number.isInteger(n) && n > 0) { + return n; + } + } + return null; +} + +function windowsInspectSubprocess( + terminalPid: number, + platform: NodeJS.Platform, +): Effect.Effect< + TerminalSubprocessInspectResult, + TerminalSubprocessCheckError, + ProcessRunner.ProcessRunner +> { + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; + return Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner.run({ + // powershell.exe is a real executable — never spawn it through cmd.exe + // shell mode, which would re-tokenize the `-Command` payload (pipes, + // semicolons) before PowerShell ever sees it. + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: "1500 millis", + maxOutputBytes: 32_768, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }); + }).pipe( + Effect.map((result) => { + if (result.code !== 0) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; + } + const processNameById = new Map(); + const childrenByParent = new Map(); + for (const line of result.stdout.split(/\r?\n/g)) { + const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const parentPid = Number(parentPidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; + processNameById.set(pid, nameRaw?.trim() ?? ""); + const children = childrenByParent.get(parentPid) ?? []; + children.push(pid); + childrenByParent.set(parentPid, children); + } + const directChildren = childrenByParent.get(terminalPid) ?? []; + const childPid = directChildren[0]; + if (childPid === undefined) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; + } + const processIds = new Set([terminalPid]); + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const pid of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(pid)) continue; + processIds.add(pid); + pending.push(pid); + } + } + const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], + } as const; + }), + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + cause, + terminalPid, + command: "powershell", + }), + ), + ); +} + +const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( + terminalPid: number, + platform: NodeJS.Platform, +): Effect.fn.Return< + TerminalSubprocessInspectResult, + TerminalSubprocessCheckError, + ProcessRunner.ProcessRunner +> { + const processRunner = yield* ProcessRunner.ProcessRunner; + const runPgrep = processRunner + .run({ + command: "pgrep", + args: ["-P", String(terminalPid)], + timeout: "1 second", + maxOutputBytes: 32_768, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + cause, + terminalPid, + command: "pgrep", + }), + ), + ); + + const runPs = processRunner + .run({ + command: "ps", + args: ["-eo", "pid=,ppid="], + timeout: "1 second", + maxOutputBytes: 262_144, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + cause, + terminalPid, + command: "ps", + }), + ), + ); + + let childPid: number | null = null; + + const pgrepResult = yield* Effect.exit(runPgrep); + if (pgrepResult._tag === "Success") { + if (pgrepResult.value.code === 0) { + childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); + } else if (pgrepResult.value.code === 1) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + } + + if (childPid === null) { + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Failure" || psResult.value.code !== 0) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + for (const line of psResult.value.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + if (ppid === terminalPid) { + childPid = pid; + break; + } + } + } + + if (childPid === null) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + + const runComm = processRunner.run({ + command: "ps", + args: ["-p", String(childPid), "-o", "comm="], + timeout: "1 second", + maxOutputBytes: 8_192, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }); + + const commResult = yield* Effect.exit(runComm); + let rawComm: string | null = null; + if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { + rawComm = commResult.value.stdout.trim(); + } + + if (!rawComm || rawComm.length === 0) { + const runArgs = processRunner.run({ + command: "ps", + args: ["-p", String(childPid), "-o", "args="], + timeout: "1 second", + maxOutputBytes: 16_384, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }); + const argsResult = yield* Effect.exit(runArgs); + if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { + const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; + rawComm = first.length > 0 ? first : null; + } + } + + const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; + const processIds = new Set([terminalPid]); + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Success" && psResult.value.code === 0) { + const childrenByParent = new Map(); + for (const line of psResult.value.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + const children = childrenByParent.get(ppid) ?? []; + children.push(pid); + childrenByParent.set(ppid, children); + } + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const child of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(child)) continue; + processIds.add(child); + pending.push(child); + } + } + } else { + processIds.add(childPid); + } + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], + }; +}); + +function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { + return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { + if (!Number.isInteger(terminalPid) || terminalPid <= 0) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + if (platform === "win32") { + return yield* windowsInspectSubprocess(terminalPid, platform); + } + return yield* posixInspectSubprocess(terminalPid, platform); + }); +} + +function capHistory(history: string, maxLines: number): string { + if (history.length === 0) return history; + const hasTrailingNewline = history.endsWith("\n"); + const lines = history.split("\n"); + if (hasTrailingNewline) { + lines.pop(); + } + if (lines.length <= maxLines) return history; + const capped = lines.slice(lines.length - maxLines).join("\n"); + return hasTrailingNewline ? `${capped}\n` : capped; +} + +function isCsiFinalByte(codePoint: number): boolean { + return codePoint >= 0x40 && codePoint <= 0x7e; +} + +function shouldStripCsiSequence(body: string, finalByte: string): boolean { + if (finalByte === "n") { + return true; + } + if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { + return true; + } + if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { + return true; + } + return false; +} + +function shouldStripOscSequence(content: string): boolean { + return /^(10|11|12);(?:\?|rgb:)/.test(content); +} + +function stripStringTerminator(value: string): string { + if (value.endsWith("\u001b\\")) { + return value.slice(0, -2); + } + const lastCharacter = value.at(-1); + if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { + return value.slice(0, -1); + } + return value; +} + +function findStringTerminatorIndex(input: string, start: number): number | null { + for (let index = start; index < input.length; index += 1) { + const codePoint = input.charCodeAt(index); + if (codePoint === 0x07 || codePoint === 0x9c) { + return index + 1; + } + if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { + return index + 2; + } + } + return null; +} + +function isEscapeIntermediateByte(codePoint: number): boolean { + return codePoint >= 0x20 && codePoint <= 0x2f; +} + +function isEscapeFinalByte(codePoint: number): boolean { + return codePoint >= 0x30 && codePoint <= 0x7e; +} + +function findEscapeSequenceEndIndex(input: string, start: number): number | null { + let cursor = start; + while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { + cursor += 1; + } + if (cursor >= input.length) { + return null; + } + return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; +} + +function sanitizeTerminalHistoryChunk( + pendingControlSequence: string, + data: string, +): { visibleText: string; pendingControlSequence: string } { + const input = `${pendingControlSequence}${data}`; + let visibleText = ""; + let index = 0; + + const append = (value: string) => { + visibleText += value; + }; + + while (index < input.length) { + const codePoint = input.charCodeAt(index); + + if (codePoint === 0x1b) { + const nextCodePoint = input.charCodeAt(index + 1); + if (Number.isNaN(nextCodePoint)) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + + if (nextCodePoint === 0x5b) { + let cursor = index + 2; + while (cursor < input.length) { + if (isCsiFinalByte(input.charCodeAt(cursor))) { + const sequence = input.slice(index, cursor + 1); + const body = input.slice(index + 2, cursor); + if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + append(sequence); + } + index = cursor + 1; + break; + } + cursor += 1; + } + if (cursor >= input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + continue; + } + + if ( + nextCodePoint === 0x5d || + nextCodePoint === 0x50 || + nextCodePoint === 0x5e || + nextCodePoint === 0x5f + ) { + const terminatorIndex = findStringTerminatorIndex(input, index + 2); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); + if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { + append(sequence); + } + index = terminatorIndex; + continue; + } + + const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); + if (escapeSequenceEndIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + append(input.slice(index, escapeSequenceEndIndex)); + index = escapeSequenceEndIndex; + continue; + } + + if (codePoint === 0x9b) { + let cursor = index + 1; + while (cursor < input.length) { + if (isCsiFinalByte(input.charCodeAt(cursor))) { + const sequence = input.slice(index, cursor + 1); + const body = input.slice(index + 1, cursor); + if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + append(sequence); + } + index = cursor + 1; + break; + } + cursor += 1; + } + if (cursor >= input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + continue; + } + + if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { + const terminatorIndex = findStringTerminatorIndex(input, index + 1); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); + if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { + append(sequence); + } + index = terminatorIndex; + continue; + } + + append(input[index] ?? ""); + index += 1; + } + + return { visibleText, pendingControlSequence: "" }; +} + +function legacySafeThreadId(threadId: string): string { + return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +function toSafeThreadId(threadId: string): string { + return `terminal_${Encoding.encodeBase64Url(threadId)}`; +} + +function toSafeTerminalId(terminalId: string): string { + return Encoding.encodeBase64Url(terminalId); +} + +function toSessionKey(threadId: string, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; +} + +function shouldExcludeTerminalEnvKey(key: string): boolean { + const normalizedKey = key.toUpperCase(); + if (normalizedKey.startsWith("T3CODE_")) { + return true; + } + if (normalizedKey.startsWith("VITE_")) { + return true; + } + return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); +} + +function createTerminalSpawnEnv( + baseEnv: NodeJS.ProcessEnv, + runtimeEnv?: Record | null, +): NodeJS.ProcessEnv { + const spawnEnv: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(baseEnv)) { + if (value === undefined) continue; + if (shouldExcludeTerminalEnvKey(key)) continue; + spawnEnv[key] = value; + } + if (runtimeEnv) { + for (const [key, value] of Object.entries(runtimeEnv)) { + spawnEnv[key] = value; + } + } + return spawnEnv; +} + +function normalizedRuntimeEnv( + env: Record | undefined, +): Record | null { + if (!env) return null; + const entries = Object.entries(env); + if (entries.length === 0) return null; + return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); +} + +interface TerminalManagerOptions { + logsDir: string; + historyLineLimit?: number; + ptyAdapter: PtyAdapter.PtyAdapter["Service"]; + shellResolver?: () => string; + env?: NodeJS.ProcessEnv; + subprocessInspector?: TerminalSubprocessInspector; + subprocessPollIntervalMs?: number; + processKillGraceMs?: number; + maxRetainedInactiveSessions?: number; + registerTerminalProcesses?: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray; + }) => Effect.Effect; + unregisterTerminal?: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; +} + +export const make = Effect.fn("TerminalManager.make")(function* () { + const { terminalLogsDir } = yield* ServerConfig.ServerConfig; + const ptyAdapter = yield* PtyAdapter.PtyAdapter; + const portDiscovery = yield* PortScanner.PortDiscovery; + return yield* makeWithOptions({ + logsDir: terminalLogsDir, + ptyAdapter, + registerTerminalProcesses: portDiscovery.registerTerminalProcesses, + unregisterTerminal: portDiscovery.unregisterTerminal, + }); +}); + +export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(function* ( + options: TerminalManagerOptions, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const logsDir = options.logsDir; + const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; + const platform = yield* HostProcessPlatform; + // Terminals must inherit the user's full environment (minus the blocklist + // applied in createTerminalSpawnEnv) — an allowlist here silently strips + // things like PSModulePath, DISPLAY, proxies, and toolchain variables. + // `options.env` is the test seam. + const baseEnv = options.env ?? process.env; + const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); + const processRunner = yield* ProcessRunner.ProcessRunner; + const subprocessInspector = + options.subprocessInspector ?? + ((terminalPid) => + defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + )); + const subprocessPollIntervalMs = + options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; + const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; + const maxRetainedInactiveSessions = + options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; + const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); + const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); + + yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); + + const managerStateRef = yield* SynchronizedRef.make({ + sessions: new Map(), + killFibers: new Map(), + }); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const workerScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); + + const publishEvent = (event: TerminalEvent) => + Effect.gen(function* () { + for (const listener of terminalEventListeners) { + yield* listener(event).pipe(Effect.ignoreCause({ log: true })); + } + }); + + const historyPath = (threadId: string, terminalId: string) => { + const threadPart = toSafeThreadId(threadId); + if (terminalId === DEFAULT_TERMINAL_ID) { + return path.join(logsDir, `${threadPart}.log`); + } + return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); + }; + + const legacyHistoryPath = (threadId: string) => + path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); + + const readManagerState = SynchronizedRef.get(managerStateRef); + + const modifyManagerState = ( + f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], + ) => SynchronizedRef.modify(managerStateRef, f); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = ( + threadId: string, + effect: Effect.Effect, + ): Effect.Effect => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( + process: PtyAdapter.PtyProcess | null, + ) { + if (!process) return; + const fiber: Option.Option> = yield* modifyManagerState< + Option.Option> + >((state) => { + const existing: Option.Option> = Option.fromNullishOr( + state.killFibers.get(process), + ); + if (Option.isNone(existing)) { + return [Option.none>(), state] as const; + } + const killFibers = new Map(state.killFibers); + killFibers.delete(process); + return [existing, { ...state, killFibers }] as const; + }); + if (Option.isSome(fiber)) { + yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore); + } + }); + + const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* ( + process: PtyAdapter.PtyProcess, + fiber: Fiber.Fiber, + ) { + yield* modifyManagerState((state) => { + const killFibers = new Map(state.killFibers); + killFibers.set(process, fiber); + return [undefined, { ...state, killFibers }] as const; + }); + }); + + const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* ( + process: PtyAdapter.PtyProcess, + threadId: string, + terminalId: string, + ) { + const terminated = yield* Effect.try({ + try: () => process.kill("SIGTERM"), + catch: (cause) => + new TerminalProcessSignalError({ + cause, + signal: "SIGTERM", + terminalPid: process.pid, + }), + }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("failed to kill terminal process", { + threadId, + terminalId, + signal: "SIGTERM", + cause: error, + }).pipe(Effect.as(false)), + ), + ); + if (!terminated) { + return; + } + + yield* Effect.sleep(processKillGraceMs); + + yield* Effect.try({ + try: () => process.kill("SIGKILL"), + catch: (cause) => + new TerminalProcessSignalError({ + cause, + signal: "SIGKILL", + terminalPid: process.pid, + }), + }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to force-kill terminal process", { + threadId, + terminalId, + signal: "SIGKILL", + cause: error, + }), + ), + ); + }); + + const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( + process: PtyAdapter.PtyProcess, + threadId: string, + terminalId: string, + ) { + const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( + Effect.ensuring( + modifyManagerState((state) => { + if (!state.killFibers.has(process)) { + return [undefined, state] as const; + } + const killFibers = new Map(state.killFibers); + killFibers.delete(process); + return [undefined, { ...state, killFibers }] as const; + }), + ), + Effect.forkIn(workerScope), + ); + + yield* registerKillFiber(process, fiber); + }); + + const persistWorker = yield* makeKeyedCoalescingWorker< + string, + PersistHistoryRequest, + never, + never + >({ + merge: (current, next) => ({ + history: next.history, + immediate: current.immediate || next.immediate, + }), + process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) { + if (!request.immediate) { + yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS); + } + + const [threadId, terminalId] = sessionKey.split("\u0000"); + if (!threadId || !terminalId) { + return; + } + + yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( + Effect.catch((error) => + Effect.logWarning("failed to persist terminal history", { + threadId, + terminalId, + error, + }), + ), + ); + }), + }); + + const queuePersist = Effect.fn("terminal.queuePersist")(function* ( + threadId: string, + terminalId: string, + history: string, + ) { + yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { + history, + immediate: false, + }); + }); + + const flushPersist = Effect.fn("terminal.flushPersist")(function* ( + threadId: string, + terminalId: string, + ) { + yield* persistWorker.drainKey(toSessionKey(threadId, terminalId)); + }); + + const persistHistory = Effect.fn("terminal.persistHistory")(function* ( + threadId: string, + terminalId: string, + history: string, + ) { + yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { + history, + immediate: true, + }); + yield* flushPersist(threadId, terminalId); + }); + + const readHistory = Effect.fn("terminal.readHistory")(function* ( + threadId: string, + terminalId: string, + ) { + const nextPath = historyPath(threadId, terminalId); + if ( + yield* fileSystem + .exists(nextPath) + .pipe( + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ) + ) { + const raw = yield* fileSystem + .readFileString(nextPath) + .pipe( + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ); + const capped = capHistory(raw, historyLineLimit); + if (capped !== raw) { + yield* fileSystem + .writeFileString(nextPath, capped) + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }), + ), + ); + } + return capped; + } + + if (terminalId !== DEFAULT_TERMINAL_ID) { + return ""; + } + + const legacyPath = legacyHistoryPath(threadId); + if ( + !(yield* fileSystem + .exists(legacyPath) + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + )) + ) { + return ""; + } + + const raw = yield* fileSystem + .readFileString(legacyPath) + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); + const capped = capHistory(raw, historyLineLimit); + yield* fileSystem + .writeFileString(nextPath, capped) + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); + yield* fileSystem.remove(legacyPath, { force: true }).pipe( + Effect.catch((cleanupError) => + Effect.logWarning("failed to remove legacy terminal history", { + threadId, + error: cleanupError, + }), + ), + ); + return capped; + }); + + const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( + threadId: string, + terminalId: string, + ) { + yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to delete terminal history", { + threadId, + terminalId, + error, + }), + ), + ); + if (terminalId === DEFAULT_TERMINAL_ID) { + yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to delete terminal history", { + threadId, + terminalId, + error, + }), + ), + ); + } + }); + + const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( + threadId: string, + ) { + const threadPrefix = `${toSafeThreadId(threadId)}_`; + const entries = yield* fileSystem + .readDirectory(logsDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + yield* Effect.forEach( + entries.filter( + (name) => + name === `${toSafeThreadId(threadId)}.log` || + name === `${legacySafeThreadId(threadId)}.log` || + name.startsWith(threadPrefix), + ), + (name) => + fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to delete terminal histories for thread", { + threadId, + error, + }), + ), + ), + { discard: true }, + ); + }); + + const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { + const stats = yield* fileSystem.stat(cwd).pipe( + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? new TerminalCwdNotFoundError({ cwd }) + : new TerminalCwdStatError({ cwd, cause }), + }), + ); + if (stats.type !== "Directory") { + return yield* new TerminalCwdNotDirectoryError({ cwd }); + } + }); + + const getSession = Effect.fn("terminal.getSession")(function* ( + threadId: string, + terminalId: string, + ): Effect.fn.Return> { + return yield* Effect.map(readManagerState, (state) => + Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))), + ); + }); + + const requireSession = Effect.fn("terminal.requireSession")(function* ( + threadId: string, + terminalId: string, + ): Effect.fn.Return { + return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => + Option.match(session, { + onNone: () => + Effect.fail( + new TerminalSessionLookupError({ + threadId, + terminalId, + }), + ), + onSome: Effect.succeed, + }), + ); + }); + + const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) { + return yield* readManagerState.pipe( + Effect.map((state) => + [...state.sessions.values()].filter((session) => session.threadId === threadId), + ), + ); + }); + + const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")( + function* () { + yield* modifyManagerState((state) => { + const inactiveSessions = [...state.sessions.values()].filter( + (session) => session.status !== "running", + ); + if (inactiveSessions.length <= maxRetainedInactiveSessions) { + return [undefined, state] as const; + } + + inactiveSessions.sort( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || + left.threadId.localeCompare(right.threadId) || + left.terminalId.localeCompare(right.terminalId), + ); + + const sessions = new Map(state.sessions); + + const toEvict = inactiveSessions.length - maxRetainedInactiveSessions; + for (const session of inactiveSessions.slice(0, toEvict)) { + const key = toSessionKey(session.threadId, session.terminalId); + sessions.delete(key); + } + + return [undefined, { ...state, sessions }] as const; + }); + }, + ); + + const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( + session: TerminalSessionState, + expectedPid: number, + ) { + while (true) { + const action: DrainProcessEventAction = yield* Effect.sync(() => { + if (session.pid !== expectedPid || !session.process || session.status !== "running") { + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + return { type: "idle" } as const; + } + + const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex]; + if (!nextEvent) { + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + return { type: "idle" } as const; + } + + session.pendingProcessEventIndex += 1; + if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + } + + if (nextEvent.type === "output") { + const sanitized = sanitizeTerminalHistoryChunk( + session.pendingHistoryControlSequence, + nextEvent.data, + ); + session.pendingHistoryControlSequence = sanitized.pendingControlSequence; + if (sanitized.visibleText.length > 0) { + session.history = capHistory( + `${session.history}${sanitized.visibleText}`, + historyLineLimit, + ); + } + const eventStamp = advanceEventSequence(session); + + return { + type: "output", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: eventStamp.sequence, + history: sanitized.visibleText.length > 0 ? session.history : null, + data: nextEvent.data, + } as const; + } + + const process = session.process; + cleanupProcessHandles(session); + session.process = null; + session.pid = null; + session.hasRunningSubprocess = false; + session.childCommandLabel = null; + session.status = "exited"; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + session.exitCode = Number.isInteger(nextEvent.event.exitCode) + ? nextEvent.event.exitCode + : null; + session.exitSignal = Number.isInteger(nextEvent.event.signal) + ? nextEvent.event.signal + : null; + const eventStamp = advanceEventSequence(session); + + return { + type: "exit", + process, + threadId: session.threadId, + terminalId: session.terminalId, + sequence: eventStamp.sequence, + exitCode: session.exitCode, + exitSignal: session.exitSignal, + } as const; + }); + + if (action.type === "idle") { + return; + } + + if (action.type === "output") { + if (action.history !== null) { + yield* queuePersist(action.threadId, action.terminalId, action.history); + } + + yield* publishEvent({ + type: "output", + threadId: action.threadId, + terminalId: action.terminalId, + sequence: action.sequence, + data: action.data, + }); + continue; + } + + yield* clearKillFiber(action.process); + yield* unregisterTerminal({ + threadId: action.threadId, + terminalId: action.terminalId, + }); + yield* publishEvent({ + type: "exited", + threadId: action.threadId, + terminalId: action.terminalId, + sequence: action.sequence, + exitCode: action.exitCode, + exitSignal: action.exitSignal, + }); + yield* evictInactiveSessionsIfNeeded(); + return; + } + }); + + const stopProcess = Effect.fn("terminal.stopProcess")(function* (session: TerminalSessionState) { + const process = session.process; + if (!process) return; + + const updatedAt = yield* nowIso; + yield* modifyManagerState((state) => { + cleanupProcessHandles(session); + session.process = null; + session.pid = null; + session.hasRunningSubprocess = false; + session.childCommandLabel = null; + session.status = "exited"; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + session.updatedAt = updatedAt; + return [undefined, state] as const; + }); + + yield* clearKillFiber(process); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); + yield* startKillEscalation(process, session.threadId, session.terminalId); + yield* evictInactiveSessionsIfNeeded(); + }); + + const trySpawn = Effect.fn("terminal.trySpawn")(function* ( + shellCandidates: ReadonlyArray, + spawnEnv: NodeJS.ProcessEnv, + session: TerminalSessionState, + index = 0, + lastError: PtyAdapter.PtySpawnError | null = null, + ): Effect.fn.Return< + { process: PtyAdapter.PtyProcess; shellLabel: string }, + PtyAdapter.PtySpawnError + > { + if (index >= shellCandidates.length) { + return yield* new PtyAdapter.PtySpawnError({ + adapter: "terminal-manager", + attemptedShells: shellCandidates.map((candidate) => formatShellCandidate(candidate)), + ...(lastError ? { cause: lastError } : {}), + }); + } + + const candidate = shellCandidates[index]; + if (!candidate) { + return yield* ( + lastError ?? + new PtyAdapter.PtySpawnError({ + adapter: "terminal-manager", + attemptedShells: [], + }) + ); + } + + const attempt = yield* Effect.result( + options.ptyAdapter.spawn({ + shell: candidate.shell, + ...(candidate.args ? { args: candidate.args } : {}), + cwd: session.cwd, + cols: session.cols, + rows: session.rows, + env: spawnEnv, + }), + ); + + if (attempt._tag === "Success") { + return { + process: attempt.success, + shellLabel: formatShellCandidate(candidate), + }; + } + + const spawnError = attempt.failure; + if (!isRetryableShellSpawnError(spawnError)) { + return yield* spawnError; + } + + return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); + }); + + const startSession = Effect.fn("terminal.startSession")(function* ( + session: TerminalSessionState, + input: TerminalStartInput, + eventType: "started" | "restarted", + ) { + yield* stopProcess(session); + yield* Effect.annotateCurrentSpan({ + "terminal.thread_id": session.threadId, + "terminal.id": session.terminalId, + "terminal.event_type": eventType, + "terminal.cwd": input.cwd, + }); + + const startingAt = yield* nowIso; + yield* modifyManagerState((state) => { + session.status = "starting"; + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.cols = input.cols; + session.rows = input.rows; + session.exitCode = null; + session.exitSignal = null; + session.hasRunningSubprocess = false; + session.childCommandLabel = null; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + session.updatedAt = startingAt; + return [undefined, state] as const; + }); + + let ptyProcess: PtyAdapter.PtyProcess | null = null; + let startedShell: string | null = null; + + const startResult = yield* Effect.result( + increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( + Effect.andThen( + Effect.gen(function* () { + const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); + const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); + const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); + ptyProcess = spawnResult.process; + startedShell = spawnResult.shellLabel; + + const processPid = ptyProcess.pid; + const unsubscribeData = ptyProcess.onData((data) => { + if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { + return; + } + runFork(drainProcessEvents(session, processPid)); + }); + const unsubscribeExit = ptyProcess.onExit((event) => { + if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { + return; + } + runFork(drainProcessEvents(session, processPid)); + }); + + let eventStamp: ReturnType = { + updatedAt: session.updatedAt, + sequence: session.eventSequence, + }; + yield* modifyManagerState((state) => { + session.process = ptyProcess; + session.pid = processPid; + session.status = "running"; + session.unsubscribeData = unsubscribeData; + session.unsubscribeExit = unsubscribeExit; + eventStamp = advanceEventSequence(session); + return [undefined, state] as const; + }); + + yield* publishEvent({ + type: eventType, + threadId: session.threadId, + terminalId: session.terminalId, + sequence: eventStamp.sequence, + snapshot: snapshot(session), + }); + }), + ), + ), + ); + + if (startResult._tag === "Success") { + return; + } + + { + const error = startResult.failure; + if (ptyProcess) { + yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); + } + + yield* modifyManagerState((state) => { + cleanupProcessHandles(session); + session.status = "error"; + session.pid = null; + session.process = null; + session.hasRunningSubprocess = false; + session.childCommandLabel = null; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + advanceEventSequence(session); + return [undefined, state] as const; + }); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); + + yield* evictInactiveSessionsIfNeeded(); + + const message = error.message; + yield* publishEvent({ + type: "error", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: session.eventSequence, + message, + }); + yield* Effect.logError("failed to start terminal", { + threadId: session.threadId, + terminalId: session.terminalId, + cause: error, + ...(startedShell ? { shell: startedShell } : {}), + }); + } + }); + + const closeSession = Effect.fn("terminal.closeSession")(function* ( + threadId: string, + terminalId: string, + deleteHistoryOnClose: boolean, + ) { + const key = toSessionKey(threadId, terminalId); + const session = yield* getSession(threadId, terminalId); + const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; + + if (Option.isSome(session)) { + yield* stopProcess(session.value); + yield* unregisterTerminal({ threadId, terminalId }); + yield* persistHistory(threadId, terminalId, session.value.history); + } + + yield* flushPersist(threadId, terminalId); + + const removed = yield* modifyManagerState((state) => { + if (!state.sessions.has(key)) { + return [false, state] as const; + } + const sessions = new Map(state.sessions); + sessions.delete(key); + return [true, { ...state, sessions }] as const; + }); + + if (removed) { + yield* publishEvent({ + type: "closed", + threadId, + terminalId, + sequence: closedEventSequence, + }); + } + + if (deleteHistoryOnClose) { + yield* deleteHistory(threadId, terminalId); + } + }); + + const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { + const state = yield* readManagerState; + const runningSessions = [...state.sessions.values()].filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && Number.isInteger(session.pid), + ); + + if (runningSessions.length === 0) { + return; + } + + const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( + session: TerminalSessionState & { pid: number }, + ) { + const terminalPid = session.pid; + const inspectResult = yield* subprocessInspector(terminalPid).pipe( + Effect.map(Option.some), + Effect.catch((reason) => + Effect.logWarning("failed to check terminal subprocess activity", { + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid, + reason, + }).pipe(Effect.as(Option.none())), + ), + ); + + if (Option.isNone(inspectResult)) { + return; + } + + const next = inspectResult.value; + yield* registerTerminalProcesses({ + threadId: session.threadId, + terminalId: session.terminalId, + processIds: next.processIds, + }); + const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; + const event = yield* modifyManagerState((state) => { + const liveSession: Option.Option = Option.fromNullishOr( + state.sessions.get(toSessionKey(session.threadId, session.terminalId)), + ); + if ( + Option.isNone(liveSession) || + liveSession.value.status !== "running" || + liveSession.value.pid !== terminalPid || + (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && + liveSession.value.childCommandLabel === nextChildLabel) + ) { + return [Option.none(), state] as const; + } + + liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; + liveSession.value.childCommandLabel = nextChildLabel; + const eventStamp = advanceEventSequence(liveSession.value); + + return [ + Option.some({ + type: "activity" as const, + threadId: liveSession.value.threadId, + terminalId: liveSession.value.terminalId, + sequence: eventStamp.sequence, + hasRunningSubprocess: next.hasRunningSubprocess, + label: terminalWireLabel(liveSession.value), + }), + state, + ] as const; + }); + + if (Option.isSome(event)) { + yield* publishEvent(event.value); + } + }); + + yield* Effect.forEach(runningSessions, checkSubprocessActivity, { + concurrency: "unbounded", + discard: true, + }); + }); + + const hasRunningSessions = readManagerState.pipe( + Effect.map((state) => + [...state.sessions.values()].some((session) => session.status === "running"), + ), + ); + + yield* Effect.forever( + hasRunningSessions.pipe( + Effect.flatMap((active) => + active + ? pollSubprocessActivity().pipe( + Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), + ) + : Effect.sleep(subprocessPollIntervalMs), + ), + ), + ).pipe(Effect.forkIn(workerScope)); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const sessions = yield* modifyManagerState( + (state) => + [ + [...state.sessions.values()], + { + ...state, + sessions: new Map(), + }, + ] as const, + ); + + const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( + session: TerminalSessionState, + ) { + cleanupProcessHandles(session); + if (!session.process) return; + yield* clearKillFiber(session.process); + yield* runKillEscalation(session.process, session.threadId, session.terminalId); + }); + + yield* Effect.forEach(sessions, cleanupSession, { + concurrency: "unbounded", + discard: true, + }); + }).pipe(Effect.ignoreCause({ log: true })), + ); + + const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existing = yield* getSession(input.threadId, terminalId); + if (Option.isNone(existing)) { + yield* flushPersist(input.threadId, terminalId); + const history = yield* readHistory(input.threadId, terminalId); + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + const session: TerminalSessionState = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history, + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + + yield* evictInactiveSessionsIfNeeded(); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "started", + ); + return snapshot(session); + } + + const liveSession = existing.value; + const nextRuntimeEnv = normalizedRuntimeEnv(input.env); + const currentRuntimeEnv = liveSession.runtimeEnv; + const targetCols = input.cols ?? liveSession.cols; + const targetRows = input.rows ?? liveSession.rows; + const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); + const nextWorktreePath = + input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; + const launchContextChanged = + liveSession.cwd !== input.cwd || + runtimeEnvChanged || + liveSession.worktreePath !== nextWorktreePath; + + if (launchContextChanged) { + yield* stopProcess(liveSession); + liveSession.cwd = input.cwd; + liveSession.worktreePath = nextWorktreePath; + liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.history = ""; + liveSession.pendingHistoryControlSequence = ""; + liveSession.pendingProcessEvents = []; + liveSession.pendingProcessEventIndex = 0; + liveSession.processEventDrainRunning = false; + yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); + } else if (liveSession.status === "exited" || liveSession.status === "error") { + liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.worktreePath = nextWorktreePath; + liveSession.history = ""; + liveSession.pendingHistoryControlSequence = ""; + liveSession.pendingProcessEvents = []; + liveSession.pendingProcessEventIndex = 0; + liveSession.processEventDrainRunning = false; + yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); + } + + if (!liveSession.process) { + yield* startSession( + liveSession, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: liveSession.worktreePath, + cols: targetCols, + rows: targetRows, + ...(input.env ? { env: input.env } : {}), + }, + "started", + ); + return snapshot(liveSession); + } + + if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { + yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows); + liveSession.cols = targetCols; + liveSession.rows = targetRows; + liveSession.updatedAt = yield* nowIso; + } + + return snapshot(liveSession); + }); + + const open: TerminalManager["Service"]["open"] = (input) => + withThreadLock(input.threadId, openLocked(input)); + + const openOrAttachForStream = (input: TerminalAttachInput) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const terminalId = input.terminalId; + const existing = yield* getSession(input.threadId, terminalId); + + if (Option.isNone(existing)) { + if (!input.cwd) { + return yield* new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId, + }); + } + + return yield* openLocked({ + ...input, + terminalId, + cwd: input.cwd, + }); + } + + const session = existing.value; + const targetCols = input.cols ?? session.cols; + const targetRows = input.rows ?? session.rows; + + if (!session.process && input.cwd && input.restartIfNotRunning === true) { + return yield* openLocked({ + ...input, + terminalId, + cwd: input.cwd, + }); + } + + if ( + session.process && + session.status === "running" && + (session.cols !== targetCols || session.rows !== targetRows) + ) { + const process = session.process; + yield* resizePtyProcess(session, process, targetCols, targetRows); + session.cols = targetCols; + session.rows = targetRows; + session.updatedAt = yield* nowIso; + } + + return snapshot(session); + }), + ); + + const readAllTerminalMetadata = () => + readManagerState.pipe( + Effect.map((state) => + [...state.sessions.values()] + .map(summary) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || + left.threadId.localeCompare(right.threadId) || + left.terminalId.localeCompare(right.terminalId), + ), + ), + ); + + const readTerminalMetadata = (input: { + readonly threadId: string; + readonly terminalId: string; + }) => + getSession(input.threadId, input.terminalId).pipe( + Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), + ); + + const subscribe: TerminalManager["Service"]["subscribe"] = (listener) => + Effect.sync(() => { + terminalEventListeners.add(listener); + return () => { + terminalEventListeners.delete(listener); + }; + }); + + const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => { + let unsubscribe: (() => void) | null = null; + + return Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; + + unsubscribe = yield* subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } + + const attachEvent = terminalEventToAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }); + + const initialSnapshot = yield* openOrAttachForStream(input); + + yield* listener({ + type: "snapshot", + snapshot: initialSnapshot, + }); + + for (const event of bufferedEvents) { + if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { + continue; + } + + const attachEvent = terminalEventToAttachEvent(event); + if (attachEvent) { + yield* listener(attachEvent); + } + } + + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), + ), + ), + ); + }; + + const metadataEventFromTerminalEvent = ( + event: TerminalEvent, + ): Effect.Effect => { + if (!shouldPublishTerminalMetadataEvent(event)) { + return Effect.succeed(null); + } + + if (event.type === "closed") { + return Effect.succeed({ + type: "remove" as const, + threadId: event.threadId, + terminalId: event.terminalId, + }); + } + + return readTerminalMetadata({ + threadId: event.threadId, + terminalId: event.terminalId, + }).pipe( + Effect.map((terminal) => + terminal + ? { + type: "upsert" as const, + terminal, + } + : null, + ), + ); + }; + + const offerMetadataEvent = ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + event: TerminalEvent, + ) => + metadataEventFromTerminalEvent(event).pipe( + Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), + ); + + const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => { + let unsubscribe: (() => void) | null = null; + + return Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; + + unsubscribe = yield* subscribe((event) => { + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } + + return offerMetadataEvent(listener, event); + }); + + const terminals = yield* readAllTerminalMetadata(); + yield* listener({ + type: "snapshot", + terminals, + }); + + for (const event of bufferedEvents) { + yield* offerMetadataEvent(listener, event); + } + + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), + ), + ), + ); + }; + + const write: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(function* (input) { + const terminalId = input.terminalId; + const session = yield* requireSession(input.threadId, terminalId); + const process = session.process; + if (!process || session.status !== "running") { + if (session.status === "exited") return; + return yield* new TerminalNotRunningError({ + threadId: input.threadId, + terminalId, + }); + } + yield* Effect.try({ + try: () => process.write(input.data), + catch: (cause) => + new TerminalWriteError({ + threadId: input.threadId, + terminalId, + terminalPid: process.pid, + cause, + }), + }); + }); + + const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { + const session = yield* getSession(input.threadId, input.terminalId); + // ResizeObserver traffic can already be in flight when the UI closes the session. + if (Option.isNone(session)) { + return; + } + const process = session.value.process; + if (!process || session.value.status !== "running") { + return; + } + yield* resizePtyProcess(session.value, process, input.cols, input.rows); + session.value.cols = input.cols; + session.value.rows = input.rows; + session.value.updatedAt = yield* nowIso; + }); + + const resize: TerminalManager["Service"]["resize"] = (input) => + withThreadLock(input.threadId, resizeLocked(input)); + + const clear: TerminalManager["Service"]["clear"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const terminalId = input.terminalId; + const session = yield* requireSession(input.threadId, terminalId); + session.history = ""; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + const eventStamp = advanceEventSequence(session); + yield* persistHistory(input.threadId, terminalId, session.history); + yield* publishEvent({ + type: "cleared", + threadId: input.threadId, + terminalId, + sequence: eventStamp.sequence, + }); + }), + ); + + const restart: TerminalManager["Service"]["restart"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: "", + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history = ""; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }), + ); + + const close: TerminalManager["Service"]["close"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.terminalId) { + yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); + return; + } + + const threadSessions = yield* sessionsForThread(input.threadId); + yield* Effect.forEach( + threadSessions, + (session) => closeSession(input.threadId, session.terminalId, false), + { discard: true }, + ); + + if (input.deleteHistory) { + yield* deleteAllHistoryForThread(input.threadId); + } + }), + ); + + return TerminalManager.of({ + open, + attachStream, + write, + resize, + clear, + restart, + close, + subscribe, + subscribeMetadata, + }); +}); + +export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts similarity index 53% rename from apps/server/src/terminal/Layers/NodePTY.test.ts rename to apps/server/src/terminal/NodePtyAdapter.test.ts index 46840214b66..ed87440d499 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -1,12 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; -import { PtyAdapter } from "../Services/PTY.ts"; -import { layer } from "./NodePTY.ts"; +import * as NodePtyAdapter from "./NodePtyAdapter.ts"; +import * as PtyAdapter from "./PtyAdapter.ts"; const spawn = vi.fn(() => ({ pid: 42, @@ -19,7 +21,7 @@ const spawn = vi.fn(() => ({ vi.mock("node-pty", () => ({ spawn })); -const testLayer = layer.pipe( +const testLayer = NodePtyAdapter.layer.pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -31,7 +33,7 @@ const testLayer = layer.pipe( it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { - const adapter = yield* PtyAdapter; + const adapter = yield* PtyAdapter.PtyAdapter; const process = yield* adapter.spawn({ shell: "powershell.exe", args: ["-NoLogo"], @@ -56,3 +58,31 @@ it.effect("spawns through the public adapter with the provided host references", ]); }).pipe(Effect.provide(testLayer)), ); + +it.effect("reports native module load failures as structured startup defects", () => + Effect.gen(function* () { + const cause = new Error("native binding could not be loaded"); + const exit = yield* NodePtyAdapter.make(() => Promise.reject(cause)).pipe(Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.isTrue(Cause.hasDies(exit.cause)); + const error = Cause.squash(exit.cause); + assert.instanceOf(error, NodePtyAdapter.NodePtyModuleLoadError); + assert.deepInclude(error, { + _tag: "NodePtyModuleLoadError", + platform: "win32", + architecture: "x64", + }); + assert.equal(error.message, "Failed to load node-pty for win32-x64."); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ), +); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/NodePtyAdapter.ts similarity index 50% rename from apps/server/src/terminal/Layers/NodePTY.ts rename to apps/server/src/terminal/NodePtyAdapter.ts index 2b19fe4ac51..ac06e1edfab 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -1,22 +1,33 @@ -import { createRequire } from "node:module"; +import * as NodeModule from "node:module"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { PtyAdapter } from "../Services/PTY.ts"; -import { - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; + +import * as PtyAdapter from "./PtyAdapter.ts"; + +export class NodePtyModuleLoadError extends Schema.TaggedErrorClass()( + "NodePtyModuleLoadError", + { + platform: Schema.String, + architecture: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to load node-pty for ${this.platform}-${this.architecture}.`; + } +} + +type NodePtyModuleLoader = () => Promise; let didEnsureSpawnHelperExecutable = false; const resolveNodePtySpawnHelperPath = Effect.gen(function* () { - const requireForNodePty = createRequire(import.meta.url); + const requireForNodePty = NodeModule.createRequire(import.meta.url); const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const platform = yield* HostProcessPlatform; @@ -56,7 +67,7 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); }); -class NodePtyProcess implements PtyProcess { +class NodePtyProcess implements PtyAdapter.PtyProcess { private readonly process: import("node-pty").IPty; constructor(process: import("node-pty").IPty) { @@ -86,7 +97,7 @@ class NodePtyProcess implements PtyProcess { }; } - onExit(callback: (event: PtyExitEvent) => void): () => void { + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { const disposable = this.process.onExit((event) => { callback({ exitCode: event.exitCode, @@ -99,47 +110,56 @@ class NodePtyProcess implements PtyProcess { } } -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; - - const nodePty = yield* Effect.promise(() => import("node-pty")); - - const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( - ensureNodePtySpawnHelperExecutable().pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HostProcessArchitecture, architecture), - Effect.orElseSucceed(() => undefined), - ), - ); - - return { - spawn: Effect.fn(function* (input) { - yield* ensureNodePtySpawnHelperExecutableCached; - const ptyProcess = yield* Effect.try({ - try: () => - nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: platform === "win32" ? "xterm-color" : "xterm-256color", - }), - catch: (cause) => - new PtySpawnError({ - adapter: "node-pty", - message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", - cause, - }), - }); - return new NodePtyProcess(ptyProcess); +export const make = Effect.fn("NodePtyAdapter.make")(function* ( + loadNodePtyModule: NodePtyModuleLoader = () => import("node-pty"), +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + + const nodePty = yield* Effect.tryPromise({ + try: loadNodePtyModule, + catch: (cause) => + new NodePtyModuleLoadError({ + platform, + architecture, + cause, }), - } satisfies PtyAdapterShape; - }), -); + }).pipe(Effect.orDie); + + const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( + ensureNodePtySpawnHelperExecutable().pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, architecture), + Effect.orElseSucceed(() => undefined), + ), + ); + + return PtyAdapter.PtyAdapter.of({ + spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { + yield* ensureNodePtySpawnHelperExecutableCached; + const ptyProcess = yield* Effect.try({ + try: () => + nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: platform === "win32" ? "xterm-color" : "xterm-256color", + }), + catch: (cause) => + new PtyAdapter.PtySpawnError({ + adapter: "node-pty", + shell: input.shell, + cause, + }), + }); + return new NodePtyProcess(ptyProcess); + }), + }); +}); + +export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/PtyAdapter.test.ts b/apps/server/src/terminal/PtyAdapter.test.ts new file mode 100644 index 00000000000..f4ac9516537 --- /dev/null +++ b/apps/server/src/terminal/PtyAdapter.test.ts @@ -0,0 +1,34 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as PtyAdapter from "./PtyAdapter.ts"; + +const isPtySpawnError = Schema.is(PtyAdapter.PtySpawnError); + +describe("PtySpawnError", () => { + it("derives messages from structural context while preserving the full cause chain", () => { + const spawnCause = new Error("spawn /bin/zsh ENOENT"); + const adapterError = new PtyAdapter.PtySpawnError({ + adapter: "node-pty", + shell: "/bin/zsh", + cause: spawnCause, + }); + const managerError = new PtyAdapter.PtySpawnError({ + adapter: "terminal-manager", + attemptedShells: ["/bin/zsh -o nopromptsp", "/bin/bash"], + cause: adapterError, + }); + + assert(isPtySpawnError(managerError)); + assert.strictEqual( + managerError.message, + "Failed to spawn PTY process with terminal-manager. Tried shells: /bin/zsh -o nopromptsp, /bin/bash.", + ); + assert.strictEqual( + adapterError.message, + "Failed to spawn PTY process '/bin/zsh' with node-pty.", + ); + assert.strictEqual(managerError.cause, adapterError); + assert.strictEqual(adapterError.cause, spawnCause); + }); +}); diff --git a/apps/server/src/terminal/Services/PTY.ts b/apps/server/src/terminal/PtyAdapter.ts similarity index 57% rename from apps/server/src/terminal/Services/PTY.ts rename to apps/server/src/terminal/PtyAdapter.ts index 7af78810efa..67147035bb5 100644 --- a/apps/server/src/terminal/Services/PTY.ts +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -6,18 +6,28 @@ * * @module PtyAdapter */ +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; /** - * PtyError - Error type for PTY adapter operations. + * PtySpawnError - Error type for PTY spawn failures. */ export class PtySpawnError extends Schema.TaggedErrorClass()("PtySpawnError", { adapter: Schema.String, - message: Schema.String, + shell: Schema.optional(Schema.String), + attemptedShells: Schema.optional(Schema.Array(Schema.String)), cause: Schema.optional(Schema.Defect()), -}) {} +}) { + override get message(): string { + const shell = this.shell === undefined ? "" : ` '${this.shell}'`; + const attemptedShells = + this.attemptedShells === undefined || this.attemptedShells.length === 0 + ? "" + : ` Tried shells: ${this.attemptedShells.join(", ")}.`; + return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}`; + } +} export interface PtyExitEvent { exitCode: number; @@ -42,19 +52,15 @@ export interface PtySpawnInput { env: NodeJS.ProcessEnv; } -/** - * PtyAdapterShape - Service API for spawning and controlling PTY processes. - */ -export interface PtyAdapterShape { - /** - * Spawn a PTY process for a terminal session. - */ - spawn(input: PtySpawnInput): Effect.Effect; -} - /** * PtyAdapter - Service tag for PTY process integration. */ -export class PtyAdapter extends Context.Service()( - "t3/terminal/Services/PTY/PtyAdapter", -) {} +export class PtyAdapter extends Context.Service< + PtyAdapter, + { + /** + * Spawn a PTY process for a terminal session. + */ + readonly spawn: (input: PtySpawnInput) => Effect.Effect; + } +>()("t3/terminal/PtyAdapter") {} diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts deleted file mode 100644 index c7e7c95f00d..00000000000 --- a/apps/server/src/terminal/Services/Manager.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * TerminalManager - Terminal session orchestration service interface. - * - * Owns terminal lifecycle operations, output fanout, and session state - * transitions for thread-scoped terminals. - * - * @module TerminalManager - */ -import { - TerminalAttachStreamEvent, - TerminalClearInput, - TerminalCloseInput, - TerminalEvent, - TerminalCwdError, - TerminalError, - TerminalHistoryError, - TerminalMetadataStreamEvent, - TerminalNotRunningError, - TerminalAttachInput, - TerminalOpenInput, - TerminalResizeInput, - TerminalRestartInput, - TerminalSessionSnapshot, - TerminalSessionLookupError, - TerminalSessionStatus, - TerminalWriteInput, -} from "@t3tools/contracts"; -import type { PtyProcess } from "./PTY.ts"; -import * as Effect from "effect/Effect"; -import * as Context from "effect/Context"; - -export { - TerminalCwdError, - TerminalError, - TerminalHistoryError, - TerminalNotRunningError, - TerminalSessionLookupError, -}; - -export interface TerminalSessionState { - threadId: string; - terminalId: string; - cwd: string; - worktreePath: string | null; - status: TerminalSessionStatus; - pid: number | null; - history: string; - pendingHistoryControlSequence: string; - exitCode: number | null; - exitSignal: number | null; - updatedAt: string; - cols: number; - rows: number; - process: PtyProcess | null; - unsubscribeData: (() => void) | null; - unsubscribeExit: (() => void) | null; - hasRunningSubprocess: boolean; - runtimeEnv: Record | null; -} - -export interface ShellCandidate { - shell: string; - args?: string[]; -} - -export interface TerminalStartInput extends TerminalOpenInput { - cols: number; - rows: number; -} - -/** - * TerminalManagerShape - Service API for terminal session lifecycle operations. - */ -export interface TerminalManagerShape { - /** - * Open or attach to a terminal session. - * - * Reuses an existing session for the same thread/terminal id and restores - * persisted history on first open. - */ - readonly open: ( - input: TerminalOpenInput, - ) => Effect.Effect; - - /** - * Attach to a terminal and stream its initial snapshot followed by live events. - * - * Returns an unsubscribe function. - */ - readonly attachStream: ( - input: TerminalAttachInput, - listener: (event: TerminalAttachStreamEvent) => Effect.Effect, - ) => Effect.Effect<() => void, TerminalError>; - - /** - * Write input bytes to a terminal session. - */ - readonly write: (input: TerminalWriteInput) => Effect.Effect; - - /** - * Resize the PTY backing a terminal session. - */ - readonly resize: (input: TerminalResizeInput) => Effect.Effect; - - /** - * Clear terminal output history. - */ - readonly clear: (input: TerminalClearInput) => Effect.Effect; - - /** - * Restart a terminal session in place. - * - * Always resets history before spawning the new process. - */ - readonly restart: ( - input: TerminalRestartInput, - ) => Effect.Effect; - - /** - * Close an active terminal session. - * - * When `terminalId` is omitted, closes all sessions for the thread. - */ - readonly close: (input: TerminalCloseInput) => Effect.Effect; - - /** - * Subscribe to terminal runtime events with a direct callback. - * - * Returns an unsubscribe function. - */ - readonly subscribe: ( - listener: (event: TerminalEvent) => Effect.Effect, - ) => Effect.Effect<() => void>; - - /** - * Subscribe to lightweight terminal metadata with an initial full snapshot. - * - * Returns an unsubscribe function. - */ - readonly subscribeMetadata: ( - listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, - ) => Effect.Effect<() => void>; -} - -/** - * TerminalManager - Service tag for terminal session orchestration. - */ -export class TerminalManager extends Context.Service()( - "t3/terminal/Services/Manager/TerminalManager", -) {} diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 0c53dbecea0..c8fe4ead3be 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -9,13 +9,13 @@ import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; -import { ServerConfig } from "../config.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); -const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { +const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-claude-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); @@ -79,7 +79,7 @@ function withFakeClaudeEnv( homeMustBe?: string; claudeConfig?: Partial; }, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 91ad90b786e..453bb62b728 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -1,7 +1,7 @@ /** * ClaudeTextGeneration – Text generation layer using the Claude CLI. * - * Implements the same TextGenerationShape contract as CodexTextGeneration but + * Implements the same TextGeneration service contract as CodexTextGeneration but * delegates to the `claude` CLI (`claude -p`) with structured JSON output * instead of the `codex exec` CLI. * @@ -18,7 +18,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -234,133 +234,131 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); const envelope = yield* decodeClaudeOutputEnvelope(rawStdout).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Claude CLI returned unexpected output format.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude CLI returned unexpected output format.", + cause, + }), + ), + }), ); const decodeOutput = Schema.decodeEffect(outputSchemaJson); return yield* decodeOutput(envelope.structured_output).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Claude returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude returned invalid structured output.", + cause, + }), + ), + }), ); }); // --------------------------------------------------------------------------- - // TextGenerationShape methods + // TextGeneration service methods // --------------------------------------------------------------------------- - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "ClaudeTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("ClaudeTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runClaudeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runClaudeJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("ClaudeTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "ClaudeTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generated = yield* runClaudeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runClaudeJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("ClaudeTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "ClaudeTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runClaudeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runClaudeJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "ClaudeTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runClaudeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runClaudeJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizeThreadTitle(generated.title), + }; }); - return { - title: sanitizeThreadTitle(generated.title), - }; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index cf0ad7d5781..24054a95870 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -11,8 +11,8 @@ import { expect } from "vite-plus/test"; import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); @@ -21,7 +21,7 @@ const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( "gpt-5.4-mini", ); -const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { +const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-codex-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); @@ -169,7 +169,7 @@ function withFakeCodexEnv( stdinMustContain?: string; stdinMustNotContain?: string; }, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -427,7 +427,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; const attachmentId = "thread-branch-image-attachment"; const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); yield* fs.makeDirectory(attachmentsDir, { recursive: true }); @@ -465,7 +465,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; const attachmentId = "thread-1-attachment"; const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); yield* fs.makeDirectory(attachmentsDir, { recursive: true }); @@ -514,7 +514,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; const missingAttachmentId = "thread-missing-attachment"; const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 80b39af2584..0e68994fd3d 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -12,14 +12,10 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { TextGenerationError } from "@t3tools/contracts"; -import { - type BranchNameGenerationInput, - type ThreadTitleGenerationResult, - type TextGenerationShape, -} from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -50,7 +46,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); + const serverConfig = yield* Effect.service(ServerConfig.ServerConfig); const resolvedEnvironment = environment ?? process.env; type MaterializedImageAttachments = { @@ -121,7 +117,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generatePrContent" | "generateBranchName" | "generateThreadTitle", - attachments: BranchNameGenerationInput["attachments"], + attachments: TextGeneration.BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { return { imagePaths: [] }; @@ -285,127 +281,124 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }), ), Effect.flatMap(decodeOutput), - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Codex returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Codex returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe(Effect.ensuring(cleanup)); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CodexTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("CodexTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("CodexTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CodexTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generated = yield* runCodexJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("CodexTextGeneration.generateBranchName")(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateBranchName", + input.attachments, + ); + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CodexTextGeneration.generateBranchName", - )(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateBranchName", - input.attachments, - ); - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("CodexTextGeneration.generateThreadTitle")(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CodexTextGeneration.generateThreadTitle", - )(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateThreadTitle", - input.attachments, - ); - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; }); - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts index c7ca9f7086e..2dc4720dcad 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -16,27 +16,27 @@ import { expect } from "vite-plus/test"; import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { +const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-cursor-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); function makeAcpAgentWrapper(dir: string, env: Record): string { - const binDir = path.join(dir, "bin"); - const agentPath = path.join(binDir, "agent"); - mkdirSync(binDir, { recursive: true }); - writeFileSync( + const binDir = NodePath.join(dir, "bin"); + const agentPath = NodePath.join(binDir, "agent"); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.writeFileSync( agentPath, [ "#!/bin/sh", @@ -50,19 +50,19 @@ function makeAcpAgentWrapper(dir: string, env: Record): string { ].join("\n"), "utf8", ); - chmodSync(agentPath, 0o755); + NodeFS.chmodSync(agentPath, 0o755); return agentPath; } function withFakeAcpAgent( env: Record, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-acp-")); yield* Effect.addFinalizer(() => Effect.sync(() => { - rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }), ); const agentPath = makeAcpAgentWrapper(tempDir, env); @@ -76,7 +76,7 @@ function waitForFileContent(path: string): Effect.Effect { return Effect.gen(function* () { const deadline = (yield* Clock.currentTimeMillis) + 5_000; for (;;) { - const result = yield* Effect.exit(Effect.sync(() => readFileSync(path, "utf8"))); + const result = yield* Effect.exit(Effect.sync(() => NodeFS.readFileSync(path, "utf8"))); if (Exit.isSuccess(result)) { return result.value; } @@ -92,8 +92,10 @@ function waitForFileContent(path: string): Effect.Effect { it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { it.effect("uses ACP model config options instead of raw CLI model ids", () => { - const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); - const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + const requestLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-log-"), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); return withFakeAcpAgent( { @@ -123,7 +125,7 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { expect(generated.subject).toBe("Add generated commit message"); expect(generated.body).toBe("- verify cursor acp model config path"); - const requests = readFileSync(requestLogPath, "utf8") + const requests = NodeFS.readFileSync(requestLogPath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) @@ -181,7 +183,7 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { ]), ); - rmSync(requestLogDir, { recursive: true, force: true }); + NodeFS.rmSync(requestLogDir, { recursive: true, force: true }); }), ); }); @@ -235,8 +237,10 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { ); it.effect("closes the ACP child process after text generation completes", () => { - const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); - const exitLogPath = path.join(exitLogDir, "exit.log"); + const exitLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"), + ); + const exitLogPath = NodePath.join(exitLogDir, "exit.log"); return withFakeAcpAgent( { @@ -265,7 +269,7 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { const exitLog = yield* waitForFileContent(exitLogPath); expect(exitLog).toContain("exit:0"); - rmSync(exitLogDir, { recursive: true, force: true }); + NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); }), ); }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 6d72178b8ae..3e1f4eb8bbc 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -9,7 +9,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; -import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -28,30 +28,7 @@ import { const CURSOR_TIMEOUT_MS = 180_000; -function mapCursorAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} +const isTextGenerationError = Schema.is(TextGenerationError); /** * Build a Cursor text-generation closure bound to a specific `CursorSettings` @@ -111,13 +88,14 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu model: modelSelection.model, selections: modelSelection.options, mapError: ({ cause, configId, step }) => - mapCursorAcpError( + new TextGenerationError({ operation, - step === "set-config-option" - ? `Failed to set Cursor ACP config option "${configId}" for text generation.` - : "Failed to set Cursor ACP base model for text generation.", + detail: + step === "set-config-option" + ? `Failed to set Cursor ACP config option "${configId}" for text generation.` + : "Failed to set Cursor ACP base model for text generation.", cause, - ), + }), }); return yield* runtime.prompt({ @@ -140,7 +118,11 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), + : new TextGenerationError({ + operation, + detail: "Cursor ACP request failed.", + cause, + }), ), ); @@ -157,123 +139,124 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawResult)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe( Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), + : new TextGenerationError({ + operation, + detail: "Cursor ACP text generation failed.", + cause, + }), ), Effect.scoped, ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CursorTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("CursorTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runCursorJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCursorJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("CursorTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CursorTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generated = yield* runCursorJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCursorJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("CursorTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CursorTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCursorJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCursorJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CursorTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCursorJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCursorJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; }); - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 58ce165752c..85127b519b9 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -13,27 +13,27 @@ import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -const GrokTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { +const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-grok-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); function makeAcpGrokWrapper(dir: string, env: Record): string { - const binDir = path.join(dir, "bin"); - const grokPath = path.join(binDir, "grok"); - mkdirSync(binDir, { recursive: true }); - writeFileSync( + const binDir = NodePath.join(dir, "bin"); + const grokPath = NodePath.join(binDir, "grok"); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.writeFileSync( grokPath, [ "#!/bin/sh", @@ -47,19 +47,19 @@ function makeAcpGrokWrapper(dir: string, env: Record): string { ].join("\n"), "utf8", ); - chmodSync(grokPath, 0o755); + NodeFS.chmodSync(grokPath, 0o755); return grokPath; } function withFakeAcpGrok( env: Record, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-acp-")); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-grok-text-acp-")); yield* Effect.addFinalizer(() => Effect.sync(() => { - rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }), ); const binaryPath = makeAcpGrokWrapper(tempDir, env); @@ -72,7 +72,7 @@ function withFakeAcpGrok( function readJsonRpcRequests( filePath: string, ): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { - return readFileSync(filePath, "utf8") + return NodeFS.readFileSync(filePath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) @@ -81,8 +81,10 @@ function readJsonRpcRequests( it.layer(GrokTextGenerationTestLayer)("GrokTextGeneration", (it) => { it.effect("uses ACP with disabled tool capabilities and forwards the requested model id", () => { - const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-log-")); - const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + const requestLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-grok-text-log-"), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); return withFakeAcpGrok( { diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 6d7ff8e872d..1bb58216305 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -10,7 +10,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; -import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -31,30 +31,7 @@ import { const GROK_TIMEOUT_MS = 180_000; -function mapGrokAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} +const isTextGenerationError = Schema.is(TextGenerationError); export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* ( grokSettings: GrokSettings, @@ -109,11 +86,11 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), requestedModelId: resolvedModel, mapError: (cause) => - mapGrokAcpError( + new TextGenerationError({ operation, - "Failed to set Grok ACP base model for text generation.", + detail: "Failed to set Grok ACP base model for text generation.", cause, - ), + }), }); return yield* runtime.prompt({ @@ -133,7 +110,11 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => isTextGenerationError(cause) ? cause - : mapGrokAcpError(operation, "Grok ACP request failed.", cause), + : new TextGenerationError({ + operation, + detail: "Grok ACP request failed.", + cause, + }), ), ); @@ -150,123 +131,124 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); return yield* decodeOutput(extractJsonObject(trimmed)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Grok Agent returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Grok Agent returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe( Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapGrokAcpError(operation, "Grok ACP text generation failed.", cause), + : new TextGenerationError({ + operation, + detail: "Grok ACP text generation failed.", + cause, + }), ), Effect.scoped, ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "GrokTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("GrokTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runGrokJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runGrokJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("GrokTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "GrokTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generated = yield* runGrokJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runGrokJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("GrokTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "GrokTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runGrokJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runGrokJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "GrokTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runGrokJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runGrokJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; }); - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index ba1f3a0435c..558a8663b64 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,4 +1,4 @@ -import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -9,14 +9,10 @@ import * as TestClock from "effect/testing/TestClock"; import * as NetService from "@t3tools/shared/Net"; import { beforeEach, expect } from "vite-plus/test"; -import { ServerConfig } from "../config.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../provider/opencodeRuntime.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; -import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; const runtimeMock = { state: { @@ -24,8 +20,11 @@ const runtimeMock = { promptUrls: [] as string[], authHeaders: [] as Array, closeCalls: [] as string[], + sessionCreateError: undefined as unknown, + sessionResult: undefined as { data?: { id: string } } | undefined, + promptRequestError: undefined as unknown, promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | { data?: { info?: { error?: unknown }; parts?: Array } } | undefined, }, reset() { @@ -33,11 +32,14 @@ const runtimeMock = { this.state.promptUrls.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; + this.state.sessionCreateError = undefined; + this.state.sessionResult = undefined; + this.state.promptRequestError = undefined; this.state.promptResult = undefined; }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { startOpenCodeServerProcess: ({ binaryPath }) => Effect.gen(function* () { const index = runtimeMock.state.startCalls.length + 1; @@ -65,12 +67,20 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => ({ data: { id: `${baseUrl}/session` } }), + create: async () => { + if (runtimeMock.state.sessionCreateError !== undefined) { + throw runtimeMock.state.sessionCreateError; + } + return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; + }, prompt: async () => { runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); + if (runtimeMock.state.promptRequestError !== undefined) { + throw runtimeMock.state.promptRequestError; + } return ( runtimeMock.state.promptResult ?? { data: { @@ -88,10 +98,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ); }, }, - }) as unknown as ReturnType, + }) as unknown as ReturnType, loadOpenCodeInventory: () => Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "loadOpenCodeInventory", detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", cause: null, @@ -103,15 +113,22 @@ const DEFAULT_TEST_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("opencode"), model: "openai/gpt-5", }; +const DEFAULT_COMMIT_MESSAGE_INPUT = { + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, +}; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; const OpenCodeTextGenerationTestLayer = Layer.succeed( - OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble, ).pipe( Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-opencode-text-generation-test-", }), ), @@ -120,11 +137,11 @@ const OpenCodeTextGenerationTestLayer = Layer.succeed( ); const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( - OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble, ).pipe( Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-opencode-text-generation-existing-server-test-", }), ), @@ -143,10 +160,10 @@ const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ function withOpenCodeTextGeneration( settings: OpenCodeSettings, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const textGeneration = yield* makeOpenCodeTextGeneration(settings); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -225,22 +242,99 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); - it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => + it.effect("preserves the SDK cause when session creation fails", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { - runtimeMock.state.promptResult = { data: {} }; + const sdkCause = new Error("session endpoint unavailable"); + runtimeMock.state.sessionCreateError = sdkCause; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("OpenCode session.create request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("reports a missing session payload without manufacturing a cause", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = {}; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.create returned no session payload."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionPayloadError", + operation: "generateCommitMessage", + cwd: process.cwd(), + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + + it.effect("preserves the SDK cause and request context when prompting fails", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const sdkCause = new Error("prompt endpoint unavailable"); + runtimeMock.state.promptRequestError = sdkCause; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.prompt request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("returns a typed empty-output error for malformed and blank response parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [null, { type: "tool" }, { type: "text", text: " " }], + }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("OpenCode returned empty output."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationEmptyOutputError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + responsePartCount: 3, + textPartCount: 1, + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); @@ -293,16 +387,21 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { }; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("Model did not produce structured output"); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptResponseError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + providerErrorName: "StructuredOutputError", + providerMessage: "Model did not produce structured output", + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 65d3854e945..1f94f970692 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { + NonNegativeInt, TextGenerationError, type ChatAttachment, type ModelSelection, @@ -15,7 +16,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { buildBranchNamePrompt, @@ -23,28 +24,116 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; -import { - OpenCodeRuntime, - type OpenCodeServerConnection, - type OpenCodeServerProcess, - openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, - toOpenCodeFileParts, -} from "../provider/opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; -function getOpenCodePromptErrorMessage(error: unknown): string | null { +const OpenCodeTextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", +]); + +type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; + +const openCodeTextGenerationErrorContext = { + operation: OpenCodeTextGenerationOperation, + cwd: Schema.String, +}; + +export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionRequestError", + { + ...openCodeTextGenerationErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; + } +} + +export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionPayloadError", + openCodeTextGenerationErrorContext, +) { + override get message(): string { + return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; + } +} + +const openCodePromptErrorContext = { + ...openCodeTextGenerationErrorContext, + sessionId: Schema.String, + providerId: Schema.String, + modelId: Schema.String, +}; + +export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptRequestError", + { + ...openCodePromptErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; + } +} + +export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptResponseError", + { + ...openCodePromptErrorContext, + providerErrorName: Schema.optional(Schema.String), + providerMessage: Schema.String, + }, +) { + override get message(): string { + const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; + return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; + } +} + +export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationEmptyOutputError", + { + ...openCodePromptErrorContext, + responsePartCount: NonNegativeInt, + textPartCount: NonNegativeInt, + }, +) { + override get message(): string { + return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; + } +} + +interface OpenCodePromptFailure { + readonly name?: string; + readonly message: string; +} + +interface OpenCodeTextPart { + readonly type: "text"; + readonly text: string; +} + +function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { if (!error || typeof error !== "object") { return null; } + const name = + "name" in error && typeof error.name === "string" && error.name.trim().length > 0 + ? error.name.trim() + : undefined; const message = "data" in error && error.data && @@ -54,37 +143,40 @@ function getOpenCodePromptErrorMessage(error: unknown): string | null { ? error.data.message.trim() : ""; if (message.length > 0) { - return message; + return { + ...(name ? { name } : {}), + message, + }; } - if ("name" in error && typeof error.name === "string") { - const name = error.name.trim(); - return name.length > 0 ? name : null; + if (name) { + return { name, message: name }; } return null; } +function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { + return ( + part !== null && + typeof part === "object" && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ); +} + function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { return (parts ?? []) - .flatMap((part) => { - if (!part || typeof part !== "object") { - return []; - } - if (!("type" in part) || part.type !== "text") { - return []; - } - if (!("text" in part) || typeof part.text !== "string") { - return []; - } - return [part.text]; - }) + .filter(isOpenCodeTextPart) + .map((part) => part.text) .join("") .trim(); } interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeServerProcess | null; + server: OpenCodeRuntime.OpenCodeServerProcess | null; /** * The scope that owns the shared server's lifetime. Closing this scope * terminates the OpenCode child process and interrupts any fibers the @@ -101,8 +193,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" openCodeSettings: OpenCodeSettings, environment?: NodeJS.ProcessEnv, ) { - const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; + const serverConfig = yield* ServerConfig.ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const resolvedEnvironment = environment ?? process.env; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), @@ -135,7 +227,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeServerProcess, + server: OpenCodeRuntime.OpenCodeServerProcess, ) { yield* cancelIdleCloseFiber(); const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( @@ -217,7 +309,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" (cause) => new TextGenerationError({ operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }), ), @@ -240,7 +332,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }), ); - const releaseSharedServer = (server: OpenCodeServerProcess) => + const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => sharedServerMutex.withPermit( Effect.gen(function* () { if (sharedServerState.server !== server) { @@ -267,18 +359,14 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ); const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; + readonly operation: OpenCodeTextGenerationOperation; readonly cwd: string; readonly prompt: string; readonly outputSchemaJson: S; readonly modelSelection: ModelSelection; readonly attachments?: ReadonlyArray | undefined; }) { - const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); if (!parsedModel) { return yield* new TextGenerationError({ operation: input.operation, @@ -286,60 +374,127 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); } - const fileParts = toOpenCodeFileParts({ + const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); - const runAgainstServer = (server: Pick) => - Effect.tryPromise({ - try: async () => { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( + function* (server: Pick) { + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }); + const session = yield* Effect.tryPromise({ + try: () => + client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }), + catch: (cause) => + new OpenCodeTextGenerationSessionRequestError({ + operation: input.operation, + cwd: input.cwd, + cause, + }), + }); + if (!session.data) { + return yield* new OpenCodeTextGenerationSessionPayloadError({ + operation: input.operation, + cwd: input.cwd, }); - const session = await client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const promptContext = { + operation: input.operation, + cwd: input.cwd, + sessionId: session.data.id, + providerId: parsedModel.providerID, + modelId: parsedModel.modelID, + }; + + const result = yield* Effect.tryPromise({ + try: () => + client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }), + catch: (cause) => + new OpenCodeTextGenerationPromptRequestError({ + ...promptContext, + cause, + }), + }); + const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); + if (promptFailure) { + return yield* new OpenCodeTextGenerationPromptResponseError({ + ...promptContext, + ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), + providerMessage: promptFailure.message, }); - if (!session.data) { - throw new Error("OpenCode session.create returned no session payload."); - } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue( - input.modelSelection, - "variant", - ); - - const result = await client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], + } + const responseParts = result.data?.parts ?? []; + const rawText = getOpenCodeTextResponse(responseParts); + if (rawText.length === 0) { + return yield* new OpenCodeTextGenerationEmptyOutputError({ + ...promptContext, + responsePartCount: responseParts.length, + textPartCount: responseParts.filter(isOpenCodeTextPart).length, }); - const info = result.data?.info; - const errorMessage = getOpenCodePromptErrorMessage(info?.error); - if (errorMessage) { - throw new Error(errorMessage); - } - const rawText = getOpenCodeTextResponse(result.data?.parts); - if (rawText.length === 0) { - throw new Error("OpenCode returned empty output."); - } - return rawText; - }, - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - }); + } + return rawText; + }, + Effect.catchTags({ + OpenCodeTextGenerationSessionRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create request failed.", + cause, + }), + ), + OpenCodeTextGenerationSessionPayloadError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create returned no session payload.", + cause, + }), + ), + OpenCodeTextGenerationPromptRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.prompt request failed.", + cause, + }), + ), + OpenCodeTextGenerationPromptResponseError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: cause.providerMessage, + cause, + }), + ), + OpenCodeTextGenerationEmptyOutputError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode returned empty output.", + cause, + }), + ), + }), + ); const rawOutput = openCodeSettings.serverUrl.length > 0 @@ -355,114 +510,111 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + }), ); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "OpenCodeTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OpenCodeTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("OpenCodeTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "OpenCodeTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - const generated = yield* runOpenCodeJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OpenCodeTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "OpenCodeTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "OpenCodeTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, + return { + title: sanitizeThreadTitle(generated.title), + }; }); - const generated = yield* runOpenCodeJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, - }); - - return { - title: sanitizeThreadTitle(generated.title), - }; - }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index f186d934e52..9bccb9c1fc5 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -9,23 +9,24 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -import type { ProviderInstanceRegistryShape } from "../provider/Services/ProviderInstanceRegistry.ts"; -import type { TextGenerationShape } from "./TextGeneration.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as TextGeneration from "./TextGeneration.ts"; -import { makeTextGenerationFromRegistry } from "./TextGeneration.ts"; - -const makeStubTextGeneration = (overrides: Partial): TextGenerationShape => ({ - generateCommitMessage: () => - Effect.die("generateCommitMessage stub not configured for this test"), - generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), - generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), - generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), - ...overrides, -}); +const makeStubTextGeneration = ( + overrides: Partial, +): TextGeneration.TextGeneration["Service"] => + TextGeneration.TextGeneration.of({ + generateCommitMessage: () => + Effect.die("generateCommitMessage stub not configured for this test"), + generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), + generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), + generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + ...overrides, + }); const makeStubInstance = ( instanceId: ProviderInstanceId, - textGeneration: TextGenerationShape, + textGeneration: TextGeneration.TextGeneration["Service"], ): ProviderInstance => ({ instanceId, @@ -43,7 +44,7 @@ const makeStubInstance = ( const makeStubRegistry = ( instances: ReadonlyArray, -): ProviderInstanceRegistryShape => { +): ProviderInstanceRegistry.ProviderInstanceRegistry["Service"] => { const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); return { getInstance: (id) => Effect.succeed(byId.get(id)), @@ -81,7 +82,7 @@ describe("makeTextGenerationFromRegistry", () => { }), ); - const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); + const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); const result = yield* tg.generateBranchName({ cwd: process.cwd(), @@ -96,7 +97,7 @@ describe("makeTextGenerationFromRegistry", () => { it.effect("fails with TextGenerationError when the instance is unknown", () => Effect.gen(function* () { - const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); + const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([])); const result = yield* tg .generateBranchName({ diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index d5d28e638ed..e62a79afe78 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -4,10 +4,7 @@ import * as Layer from "effect/Layer"; import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import { TextGenerationError } from "@t3tools/contracts"; -import { - ProviderInstanceRegistry, - type ProviderInstanceRegistryShape, -} from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; @@ -79,45 +76,44 @@ export interface TextGenerationService { generateThreadTitle(input: ThreadTitleGenerationInput): Promise; } -/** - * TextGenerationShape - Service API for commit/PR text generation. - */ -export interface TextGenerationShape { - /** - * Generate a commit message from staged change context. - */ - readonly generateCommitMessage: ( - input: CommitMessageGenerationInput, - ) => Effect.Effect; - - /** - * Generate pull request title/body from branch and diff context. - */ - readonly generatePrContent: ( - input: PrContentGenerationInput, - ) => Effect.Effect; - - /** - * Generate a concise branch name from a user message. - */ - readonly generateBranchName: ( - input: BranchNameGenerationInput, - ) => Effect.Effect; - - /** - * Generate a concise thread title from a user's first message. - */ - readonly generateThreadTitle: ( - input: ThreadTitleGenerationInput, - ) => Effect.Effect; -} - /** * TextGeneration - Service tag for commit and PR text generation. */ -export class TextGeneration extends Context.Service()( - "t3/textGeneration/TextGeneration", -) {} +export class TextGeneration extends Context.Service< + TextGeneration, + { + /** + * Generate a commit message from staged change context. + */ + readonly generateCommitMessage: ( + input: CommitMessageGenerationInput, + ) => Effect.Effect; + + /** + * Generate pull request title/body from branch and diff context. + */ + readonly generatePrContent: ( + input: PrContentGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise branch name from a user message. + */ + readonly generateBranchName: ( + input: BranchNameGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise thread title from a user's first message. + */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; + } +>()("t3/textGeneration/TextGeneration") {} + +/** @deprecated Use `TextGeneration["Service"]`. */ +export type TextGenerationShape = TextGeneration["Service"]; type TextGenerationOp = | "generateCommitMessage" @@ -126,7 +122,7 @@ type TextGenerationOp = | "generateThreadTitle"; const resolveInstance = ( - registry: ProviderInstanceRegistryShape, + registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], operation: TextGenerationOp, instanceId: ProviderInstanceId, ): Effect.Effect => @@ -144,30 +140,30 @@ const resolveInstance = ( ); export const makeTextGenerationFromRegistry = ( - registry: ProviderInstanceRegistryShape, -): TextGenerationShape => ({ - generateCommitMessage: (input) => - resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), - ), - generatePrContent: (input) => - resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), - ), - generateBranchName: (input) => - resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), - ), - generateThreadTitle: (input) => - resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), - ), + registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], +): TextGeneration["Service"] => + TextGeneration.of({ + generateCommitMessage: (input) => + resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), + ), + generatePrContent: (input) => + resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + ), + generateBranchName: (input) => + resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + ), + generateThreadTitle: (input) => + resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), + ), + }); + +export const make = Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + return makeTextGenerationFromRegistry(registry); }); -export const layer = Layer.effect( - TextGeneration, - Effect.gen(function* () { - const registry = yield* ProviderInstanceRegistry; - return makeTextGenerationFromRegistry(registry); - }), -); +export const layer = Layer.effect(TextGeneration, make); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 1435bc522b8..b67e8b93c4a 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -190,4 +190,16 @@ describe("normalizeCliError", () => { expect(result).toBeInstanceOf(TextGenerationError); expect(result.detail).toBe("fallback"); }); + + it("does not expose CLI failure details in the public error message", () => { + const result = normalizeCliError( + "codex", + "generateCommitMessage", + new Error("request failed with access_token=secret-token"), + "Failed to generate a commit message", + ); + + expect(result.detail).toBe("Failed to generate a commit message"); + expect(result.message).not.toContain("secret-token"); + }); }); diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index a786f81b2c8..ad2911c20f7 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -99,7 +99,7 @@ export function normalizeCliError( } return new TextGenerationError({ operation, - detail: `${fallback}: ${error.message}`, + detail: fallback, cause: error, }); } diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 70bb8655ea1..89f7c55d586 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; import { GitCommandError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index adf991556d4..55aa8f38835 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -28,7 +28,7 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import * as GitVcsDriverCore from "./GitVcsDriverCore.ts"; +import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -161,6 +161,22 @@ export interface GitFetchRemoteTrackingBranchInput { remoteBranch: string; } +export interface GitFetchRemoteInput { + cwd: string; + remoteName: string; +} + +export interface GitResolveRemoteTrackingCommitInput { + cwd: string; + refName: string; + fallbackRemoteName: string; +} + +export interface GitResolveRemoteTrackingCommitResult { + commitSha: string; + remoteRefName: string; +} + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -168,76 +184,88 @@ export interface GitSetBranchUpstreamInput { remoteBranch: string; } -export interface GitVcsDriverShape { - readonly execute: (input: ExecuteGitInput) => Effect.Effect; - readonly status: (input: VcsStatusInput) => Effect.Effect; - readonly statusDetails: (cwd: string) => Effect.Effect; - readonly statusDetailsLocal: (cwd: string) => Effect.Effect; - readonly statusDetailsRemote: ( - cwd: string, - ) => Effect.Effect; - readonly prepareCommitContext: ( - cwd: string, - filePaths?: readonly string[], - ) => Effect.Effect; - readonly commit: ( - cwd: string, - subject: string, - body: string, - options?: GitCommitOptions, - ) => Effect.Effect<{ commitSha: string }, GitCommandError>; - readonly pushCurrentBranch: ( - cwd: string, - fallbackBranch: string | null, - options?: { readonly remoteName?: string | null }, - ) => Effect.Effect; - readonly readRangeContext: ( - cwd: string, - baseRef: string, - ) => Effect.Effect; - readonly getReviewDiffPreview: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; - readonly readConfigValue: ( - cwd: string, - key: string, - ) => Effect.Effect; - readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - readonly createWorktree: ( - input: VcsCreateWorktreeInput, - ) => Effect.Effect; - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, - ) => Effect.Effect; - readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; - readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; - readonly fetchRemoteBranch: ( - input: GitFetchRemoteBranchInput, - ) => Effect.Effect; - readonly fetchRemoteTrackingBranch: ( - input: GitFetchRemoteTrackingBranchInput, - ) => Effect.Effect; - readonly setBranchUpstream: ( - input: GitSetBranchUpstreamInput, - ) => Effect.Effect; - readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; - readonly renameBranch: ( - input: GitRenameBranchInput, - ) => Effect.Effect; - readonly createRef: ( - input: VcsCreateRefInput, - ) => Effect.Effect; - readonly switchRef: ( - input: VcsSwitchRefInput, - ) => Effect.Effect; - readonly initRepo: (input: VcsInitInput) => Effect.Effect; - readonly listLocalBranchNames: (cwd: string) => Effect.Effect; +export interface GitRemoteStatusOptions { + readonly refreshUpstream?: boolean; } -export class GitVcsDriver extends Context.Service()( - "t3/vcs/GitVcsDriver", -) {} +export class GitVcsDriver extends Context.Service< + GitVcsDriver, + { + readonly execute: (input: ExecuteGitInput) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; + readonly statusDetails: (cwd: string) => Effect.Effect; + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly statusDetailsRemote: ( + cwd: string, + options?: GitRemoteStatusOptions, + ) => Effect.Effect; + readonly prepareCommitContext: ( + cwd: string, + filePaths?: readonly string[], + ) => Effect.Effect; + readonly commit: ( + cwd: string, + subject: string, + body: string, + options?: GitCommitOptions, + ) => Effect.Effect<{ commitSha: string }, GitCommandError>; + readonly pushCurrentBranch: ( + cwd: string, + fallbackBranch: string | null, + options?: { readonly remoteName?: string | null }, + ) => Effect.Effect; + readonly readRangeContext: ( + cwd: string, + baseRef: string, + ) => Effect.Effect; + readonly getReviewDiffPreview: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + readonly listRefs: ( + input: VcsListRefsInput, + ) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; + readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly resolveRemoteTrackingCommit: ( + input: GitResolveRemoteTrackingCommitInput, + ) => Effect.Effect; + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + readonly fetchRemoteTrackingBranch: ( + input: GitFetchRemoteTrackingBranchInput, + ) => Effect.Effect; + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; + readonly removeWorktree: ( + input: VcsRemoveWorktreeInput, + ) => Effect.Effect; + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly initRepo: (input: VcsInitInput) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; + } +>()("t3/vcs/GitVcsDriver") {} const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; @@ -332,7 +360,7 @@ function parseGitRemoteVerboseOutput( } const gitCommand = ( - process: VcsProcess.VcsProcessShape, + process: VcsProcess.VcsProcess["Service"], operation: string, cwd: string, args: ReadonlyArray, @@ -376,7 +404,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ignoreClassifier: "native" as const, }; - const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => + const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => gitCommand( vcsProcess, "GitVcsDriver.isInsideWorkTree", @@ -389,7 +417,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); - const execute: VcsDriver.VcsDriverShape["execute"] = (input) => + const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.env !== undefined ? { env: input.env } : {}), @@ -401,7 +429,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( : {}), }); - const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn( + const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { if (!(yield* isInsideWorkTree(cwd))) { @@ -427,7 +455,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }; }); - const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => + const listWorkspaceFiles: VcsDriver.VcsDriver["Service"]["listWorkspaceFiles"] = (cwd) => gitCommand( vcsProcess, "GitVcsDriver.listWorkspaceFiles", @@ -469,7 +497,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), ); - const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( + const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn("listRemotes")( function* (cwd) { const result = yield* gitCommand( vcsProcess, @@ -515,7 +543,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ); - const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn( + const filterIgnoredPaths: VcsDriver.VcsDriver["Service"]["filterIgnoredPaths"] = Effect.fn( "filterIgnoredPaths", )(function* (cwd, relativePaths) { if (relativePaths.length === 0) { @@ -562,7 +590,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); }); - const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => + const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) => gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, @@ -623,7 +651,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = "GitVcsDriver.checkpoints.captureCheckpoint"; const gitCommonDir = yield* resolveGitCommonDir(input.cwd); - const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${randomUUID()}`); + const tempIndexPath = path.join( + gitCommonDir, + `t3-checkpoint-index-${NodeCrypto.randomUUID()}`, + ); const commitEnv: NodeJS.ProcessEnv = { ...process.env, GIT_INDEX_FILE: tempIndexPath, @@ -819,7 +850,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), }; - return VcsDriver.VcsDriver.of({ + return { capabilities, execute, checkpoints, @@ -829,18 +860,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( listRemotes, filterIgnoredPaths, initRepository, - }); + }; }); -export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { +export const makeVcsDriver = Effect.gen(function* () { const driver = yield* makeVcsDriverShape(); return VcsDriver.VcsDriver.of(driver); }); -export const make = Effect.fn("makeGitVcsDriverService")(function* () { - const git = yield* GitVcsDriverCore.makeGitVcsDriverCore(); +export const make = Effect.gen(function* () { + const git = yield* makeGitVcsDriverCore(); return GitVcsDriver.of(git); }); -export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver()); -export const layer = Layer.effect(GitVcsDriver, make()); +export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver); +export const layer = Layer.effect(GitVcsDriver, make); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 173d7649bd1..dc58fc2543c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -78,6 +78,114 @@ const initRepoWithCommit = ( }); it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("structured errors", () => { + it.effect("preserves structured spawn context and the platform cause", () => + Effect.gen(function* () { + const parent = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const cwd = pathService.join(parent, "missing"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.missingCwd", + cwd, + args: ["status", "--short"], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.missingCwd", + command: "git", + argumentCount: 2, + cwd, + detail: "Failed to spawn Git process.", + }); + if (!(error.cause instanceof PlatformError.PlatformError)) { + return assert.fail("expected the original platform error cause"); + } + assert.equal(error.cause.reason._tag, "NotFound"); + assert.notInclude(error.detail, error.cause.message); + }), + ); + + it.effect("does not retain git arguments or stderr in command failures", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + const secret = "secret-token-value"; + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.redactedFailure", + cwd, + args: ["status", `--unknown-option=${secret}`], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.redactedFailure", + command: "git", + argumentCount: 2, + cwd, + }); + assert.isNumber(error.exitCode); + assert.isAbove(error.stderrLength ?? 0, 0); + assert.notInclude(error.detail, secret); + assert.notInclude(error.message, secret); + assert.notProperty(error, "args"); + assert.notProperty(error, "stderr"); + }), + ); + + it.effect("recovers a structurally identified missing cwd as a non-repository", () => + Effect.gen(function* () { + const parent = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const cwd = pathService.join(parent, "missing"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const [localStatus, remoteStatus, refs] = yield* Effect.all([ + driver.statusDetails(cwd), + driver.statusDetailsRemote(cwd, { refreshUpstream: false }), + driver.listRefs({ cwd }), + ]); + + assert.equal(localStatus.isRepo, false); + assert.equal(remoteStatus.isRepo, false); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); + }), + ); + + it.effect("does not wrap a remove-worktree command failure in a synthetic error", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const missingWorktree = pathService.join(cwd, "missing-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + const error = yield* driver + .removeWorktree({ cwd, path: missingWorktree }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.removeWorktree", + command: "git", + argumentCount: 3, + cwd, + }); + assert.notProperty(error, "cause"); + assert.notInclude(error.detail, "Git command failed in"); + }), + ); + }); + describe("review diff previews", () => { it.effect("drops an unterminated path from truncated NUL-separated git output", () => Effect.sync(() => { @@ -100,6 +208,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]); }), ); + + it.effect("honors whitespace filtering for worktree and branch previews", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/whitespace"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "change whitespace"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + + const included = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + const ignored = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: true, + }); + + assert.isNotEmpty(included.sources.find((source) => source.kind === "working-tree")?.diff); + assert.isNotEmpty(included.sources.find((source) => source.kind === "branch-range")?.diff); + assert.strictEqual( + ignored.sources.find((source) => source.kind === "working-tree")?.diff, + "", + ); + assert.strictEqual( + ignored.sources.find((source) => source.kind === "branch-range")?.diff, + "", + ); + }), + ); }); describe("repository status", () => { @@ -183,6 +326,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("can read cached remote divergence without fetching upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const updater = yield* makeTmpDir("git-vcs-driver-updater-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + + yield* git(updater, ["clone", remote, "."]); + yield* git(updater, ["config", "user.email", "test@test.com"]); + yield* git(updater, ["config", "user.name", "Test"]); + yield* writeTextFile(updater, "remote.txt", "remote\n"); + yield* git(updater, ["add", "remote.txt"]); + yield* git(updater, ["commit", "-m", "remote commit"]); + yield* git(updater, ["push", "origin", initialBranch]); + + const driver = yield* GitVcsDriver.GitVcsDriver; + const cachedStatus = yield* driver.statusDetailsRemote(cwd, { + refreshUpstream: false, + }); + const refreshedStatus = yield* driver.statusDetailsRemote(cwd); + + assert.equal(cachedStatus.behindCount, 0); + assert.equal(refreshedStatus.behindCount, 1); + }), + ); + it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -313,6 +485,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("refName operations", () => { + it.effect("optionally includes remote refs that match local branches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const deduplicated = yield* driver.listRefs({ cwd }); + assert.equal( + deduplicated.refs.some((ref) => ref.name === `origin/${initialBranch}`), + false, + ); + + const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true }); + assert.equal( + complete.refs.some((ref) => ref.name === initialBranch), + true, + ); + assert.equal( + complete.refs.some((ref) => ref.name === `origin/${initialBranch}`), + true, + ); + + const remoteOnly = yield* driver.listRefs({ + cwd, + includeMatchingRemoteRefs: true, + refKind: "remote", + limit: 1, + }); + assert.equal(remoteOnly.refs.length, 1); + assert.equal(remoteOnly.refs[0]?.name, `origin/${initialBranch}`); + assert.equal(remoteOnly.refs[0]?.isRemote, true); + }), + ); + it.effect("creates, checks out, renames, and lists refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -413,6 +623,77 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + it.effect("creates a worktree from the latest fetched remote commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const peer = yield* makeTmpDir("git-peer-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(remote, ["symbolic-ref", "HEAD", `refs/heads/${initialBranch}`]); + const beforeFetch = yield* git(cwd, ["rev-parse", `refs/remotes/origin/${initialBranch}`]); + + yield* git(peer, ["clone", remote, "."]); + yield* git(peer, ["config", "user.email", "test@test.com"]); + yield* git(peer, ["config", "user.name", "Test"]); + yield* writeTextFile(peer, "remote-change.txt", "remote\n"); + yield* git(peer, ["add", "remote-change.txt"]); + yield* git(peer, ["commit", "-m", "remote change"]); + yield* git(peer, ["push", "origin", initialBranch]); + const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]); + assert.notEqual(beforeFetch, remoteHead); + + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.fetchRemote({ cwd, remoteName: "origin" }); + + const resolvedBase = yield* driver.resolveRemoteTrackingCommit({ + cwd, + refName: initialBranch, + fallbackRemoteName: "origin", + }); + const explicitlyResolvedBase = yield* driver.resolveRemoteTrackingCommit({ + cwd, + refName: `origin/${initialBranch}`, + fallbackRemoteName: "origin", + }); + + assert.deepEqual(resolvedBase, { + commitSha: remoteHead, + remoteRefName: `origin/${initialBranch}`, + }); + assert.deepEqual(explicitlyResolvedBase, resolvedBase); + assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch); + + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-fetched-worktrees-"), + "fetched-origin", + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: resolvedBase.commitSha, + newRefName: "t3code/fetched-origin", + baseRefName: resolvedBase.remoteRefName, + }); + + assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.gh-merge-base"), + initialBranch, + ); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"), + null, + ); + const status = yield* driver.statusDetails(worktreePath); + assert.equal(status.aheadCount, 0); + assert.equal(status.aheadOfDefaultCount, 0); + }), + ); + it.effect("pushes with upstream setup and skips when already up to date", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a763026c23f..a406cbce549 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -36,7 +36,6 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; -const isGitCommandError = Schema.is(GitCommandError); const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; @@ -100,7 +99,7 @@ interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; - fallbackErrorMessage?: string | undefined; + fallbackErrorDetail?: string | undefined; env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; appendTruncationMarker?: boolean | undefined; @@ -326,8 +325,15 @@ function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { return localBranch.length > 0 ? localBranch : null; } -function commandLabel(args: readonly string[]): string { - return `git ${args.join(" ")}`; +function gitCommandContext( + input: Pick, +) { + return { + operation: input.operation, + command: "git", + cwd: input.cwd, + argumentCount: input.args.length, + } as const; } function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { @@ -340,50 +346,28 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): return refName.length > 0 ? refName : null; } -function createGitCommandError( - operation: string, - cwd: string, - args: readonly string[], - detail: string, - cause?: unknown, -): GitCommandError { - return new GitCommandError({ - operation, - command: commandLabel(args), - cwd, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} +function isMissingGitCwdError(error: GitCommandError): boolean { + if (!(error.cause instanceof PlatformError.PlatformError)) { + return false; + } -function quoteGitCommand(args: ReadonlyArray): string { - return `git ${args.join(" ")}`; -} + const reason = error.cause.reason; + if (reason._tag === "NotFound") { + return reason.pathOrDescriptor === error.cwd; + } -function isMissingGitCwdError(error: GitCommandError): boolean { - const normalized = `${error.detail}\n${error.message}`.toLowerCase(); return ( - normalized.includes("no such file or directory") || - normalized.includes("notfound: filesystem.access") || - normalized.includes("enoent") || - normalized.includes("not a directory") + reason._tag === "BadResource" && + reason.pathOrDescriptor === error.cwd && + typeof reason.cause === "object" && + reason.cause !== null && + "code" in reason.cause && + reason.cause.code === "ENOTDIR" ); } -function toGitCommandError( - input: Pick, - detail: string, -) { - return (cause: unknown) => - isGitCommandError(cause) - ? cause - : new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, - ...(cause !== undefined ? { cause } : {}), - }); +function isNonRepositoryGitStderr(stderr: string): boolean { + return stderr.toLowerCase().includes("not a git repository"); } interface Trace2Monitor { @@ -402,7 +386,11 @@ const addCurrentSpanEvent = (name: string, attributes: Record) yield* Effect.sync(() => { span.event(name, timestamp, compactTraceAttributes(attributes)); }); - }).pipe(Effect.catch(() => Effect.void)); + }).pipe( + Effect.catchTags({ + NoSuchElementError: () => Effect.void, + }), + ); function trace2ChildKey(record: Record): string | null { const childId = record.child_id; @@ -451,7 +439,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( - `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, + `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, traceRecord.failure, ); return; @@ -574,9 +562,9 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); -const collectOutput = Effect.fnUntraced(function* ( +const collectOutput = Effect.fnUntraced(function* ( input: Pick, - stream: Stream.Stream, + stream: Stream.Stream, maxOutputBytes: number, appendTruncationMarker: boolean, onLine: ((line: string) => Effect.Effect) | undefined, @@ -614,10 +602,9 @@ const collectOutput = Effect.fnUntraced(function* ( const nextBytes = bytes + chunk.byteLength; if (!appendTruncationMarker && nextBytes > maxOutputBytes) { return yield* new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + ...gitCommandContext(input), + detail: `Git output exceeded ${maxOutputBytes} bytes and was truncated.`, + outputLength: nextBytes, }); } @@ -635,7 +622,14 @@ const collectOutput = Effect.fnUntraced(function* ( }); yield* Stream.runForEach(stream, processChunk).pipe( - Effect.mapError(toGitCommandError(input, "output stream failed.")), + Effect.catchTags({ + PlatformError: (cause) => + new GitCommandError({ + ...gitCommandContext(input), + detail: "Failed to read Git process output.", + cause, + }), + }), ); const remainder = truncated ? "" : decoder.decode(); @@ -655,7 +649,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced( + const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { const commandInput = { ...input, @@ -669,7 +663,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( Effect.provideService(Path.Path, path), Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to create Git trace monitor.", + cause, + }), + ), ); const child = yield* commandSpawner .spawn( @@ -682,7 +683,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }), ) - .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); + .pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to spawn Git process.", + cause, + }), + ), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ @@ -701,12 +711,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.progress?.onStderrLine, ), child.exitCode.pipe( - Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to read Git process exit code.", + cause, + }), + ), ), input.stdin === undefined ? Effect.void : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( - Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to write Git process input.", + cause, + }), + ), ), ], { concurrency: "unbounded" }, @@ -714,15 +738,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* trace2Monitor.flush; if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.text.trim(); return yield* new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + ...gitCommandContext(commandInput), + detail: "Git command exited with a non-zero status.", + exitCode, + stdoutLength: stdout.text.length, + stderrLength: stderr.text.length, }); } @@ -743,10 +764,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* onNone: () => Effect.fail( new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: `${quoteGitCommand(commandInput.args)} timed out.`, + ...gitCommandContext(commandInput), + detail: "Git command timed out.", }), ), onSome: Effect.succeed, @@ -756,7 +775,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const execute: GitVcsDriver.GitVcsDriverShape["execute"] = (input) => + const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => executeRaw(input).pipe( withMetrics({ counter: gitCommandsTotal, @@ -799,22 +818,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (options.allowNonZeroExit || result.exitCode === 0) { return Effect.succeed(result); } - const stderr = result.stderr.trim(); - if (stderr.length > 0) { - return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); - } - if (options.fallbackErrorMessage) { - return Effect.fail( - createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), - ); - } return Effect.fail( - createGitCommandError( - operation, - cwd, - args, - `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`, - ), + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args }), + detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), ); }), ); @@ -877,12 +888,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - return yield* createGitCommandError( - "GitVcsDriver.renameBranch", - cwd, - ["branch", "-m", "--", desiredBranch], - `Could not find an available branch name for '${desiredBranch}'.`, - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.renameBranch", + cwd, + args: ["branch", "-m", "--", desiredBranch], + }), + detail: `Could not find an available branch name for '${desiredBranch}'.`, + }); }); const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { @@ -1024,12 +1037,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (firstRemote) { return firstRemote; } - return yield* createGitCommandError( - "GitVcsDriver.resolvePrimaryRemoteName", - cwd, - ["remote"], - "No git remote is configured for this repository.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolvePrimaryRemoteName", + cwd, + args: ["remote"], + }), + detail: "No git remote is configured for this repository.", + }); }); const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( @@ -1059,38 +1074,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); - const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( - function* (input) { - const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); - const remoteFetchUrls = yield* runGitStdout( - "GitVcsDriver.ensureRemote.listRemoteUrls", - input.cwd, - ["remote", "-v"], - ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( + "ensureRemote", + )(function* (input) { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout( + "GitVcsDriver.ensureRemote.listRemoteUrls", + input.cwd, + ["remote", "-v"], + ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { - return remoteName; - } + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; } + } - let remoteName = preferredName; - let suffix = 1; - while (remoteFetchUrls.has(remoteName)) { - remoteName = `${preferredName}-${suffix}`; - suffix += 1; - } + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } - yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ - "remote", - "add", - remoteName, - input.url, - ]); - return remoteName; - }, - ); + yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ + "remote", + "add", + remoteName, + input.url, + ]); + return remoteName; + }); const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( cwd: string, @@ -1130,16 +1145,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* continue; } - if (yield* branchExists(cwd, normalizedCandidate)) { - return normalizedCandidate; - } - if ( primaryRemoteName && (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) ) { return `${primaryRemoteName}/${normalizedCandidate}`; } + + if (yield* branchExists(cwd, normalizedCandidate)) { + return normalizedCandidate; + } } return null; @@ -1174,19 +1189,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, ["rev-parse", "--abbrev-ref", "HEAD"], { allowNonZeroExit: true }, - ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); if (branchResult === null) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } if (branchResult.exitCode !== 0) { - const stderr = branchResult.stderr.trim(); - return yield* createGitCommandError( - "GitVcsDriver.statusDetailsRemote.branch", - cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], - stderr || "git branch lookup failed", - ); + if (isNonRepositoryGitStderr(branchResult.stderr)) { + return NON_REPOSITORY_REMOTE_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetailsRemote.branch", + cwd, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + }), + detail: "Git branch lookup failed.", + exitCode: branchResult.exitCode, + stdoutLength: branchResult.stdout.length, + stderrLength: branchResult.stderr.length, + }); } const branchValue = branchResult.stdout.trim(); @@ -1284,20 +1311,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { allowNonZeroExit: true, }, - ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + ).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); if (statusResult === null) { return NON_REPOSITORY_STATUS_DETAILS; } if (statusResult.exitCode !== 0) { - const stderr = statusResult.stderr.trim(); - return yield* createGitCommandError( - "GitVcsDriver.statusDetails.status", - cwd, - ["status", "--porcelain=2", "--branch"], - stderr || "git status failed", - ); + if (isNonRepositoryGitStderr(statusResult.stderr)) { + return NON_REPOSITORY_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.status", + cwd, + args: ["status", "--porcelain=2", "--branch"], + }), + detail: "Git status failed.", + exitCode: statusResult.exitCode, + stdoutLength: statusResult.stdout.length, + stderrLength: statusResult.stderr.length, + }); } const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = @@ -1426,33 +1465,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const statusDetailsLocal: GitVcsDriver.GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( + const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn( "statusDetailsLocal", )(function* (cwd) { return yield* readStatusDetailsLocal(cwd); }); - const statusDetails: GitVcsDriver.GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( - function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), - Effect.ignoreCause({ log: true }), - ); - return yield* readStatusDetailsLocal(cwd); - }, - ); - - const statusDetailsRemote: GitVcsDriver.GitVcsDriverShape["statusDetailsRemote"] = Effect.fn( - "statusDetailsRemote", + const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn( + "statusDetails", )(function* (cwd) { yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), + }), Effect.ignoreCause({ log: true }), ); - return yield* readStatusDetailsRemote(cwd); + return yield* readStatusDetailsLocal(cwd); }); - const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) => + const statusDetailsRemote: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsRemote"] = + Effect.fn("statusDetailsRemote")(function* (cwd, options) { + if (options?.refreshUpstream !== false) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.void : Effect.fail(error), + }), + Effect.ignoreCause({ log: true }), + ); + } + return yield* readStatusDetailsRemote(cwd); + }); + + const status: GitVcsDriver.GitVcsDriver["Service"]["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ isRepo: details.isRepo, @@ -1469,49 +1515,50 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* })), ); - const prepareCommitContext: GitVcsDriver.GitVcsDriverShape["prepareCommitContext"] = Effect.fn( - "prepareCommitContext", - )(function* (cwd, filePaths) { - if (filePaths && filePaths.length > 0) { - yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catch(() => Effect.void), - ); - yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ - "add", - "-A", - "--", - ...filePaths, - ]); - } else { - yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); - } + const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = + Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { + if (filePaths && filePaths.length > 0) { + yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catchTags({ + GitCommandError: () => Effect.void, + }), + ); + yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } - const stagedSummary = yield* runGitStdout( - "GitVcsDriver.prepareCommitContext.stagedSummary", - cwd, - ["diff", "--cached", "--name-status"], - ).pipe(Effect.map((stdout) => stdout.trim())); - if (stagedSummary.length === 0) { - return null; - } + const stagedSummary = yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.stagedSummary", + cwd, + ["diff", "--cached", "--name-status"], + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } - const stagedPatch = yield* runGitStdoutWithOptions( - "GitVcsDriver.prepareCommitContext.stagedPatch", - cwd, - ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], - { - maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ); + const stagedPatch = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + { + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); - return { - stagedSummary, - stagedPatch, - }; - }); + return { + stagedSummary, + stagedPatch, + }; + }); - const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( + const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* ( cwd, subject, body, @@ -1544,18 +1591,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); - const pushCurrentBranch: GitVcsDriver.GitVcsDriverShape["pushCurrentBranch"] = Effect.fn( + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { const details = yield* statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push from detached HEAD.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push from detached HEAD.", + }); } const requestedRemoteName = options?.remoteName?.trim() || null; @@ -1614,12 +1663,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (!details.hasUpstream) { const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); if (!publishRemoteName) { - return yield* createGitCommandError( - "GitVcsDriver.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push because no git remote is configured for this repository.", + }); } const publishBranch = yield* resolvePublishBranchName(cwd, branch); yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ @@ -1662,26 +1713,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const pullCurrentBranch: GitVcsDriver.GitVcsDriverShape["pullCurrentBranch"] = Effect.fn( + const pullCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = Effect.fn( "pullCurrentBranch", )(function* (cwd) { const details = yield* statusDetails(cwd); const refName = details.branch; if (!refName) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pullCurrentBranch", + cwd, + args: ["pull", "--ff-only"], + }), + detail: "Cannot pull from detached HEAD.", + }); } if (!details.hasUpstream) { - return yield* createGitCommandError( - "GitVcsDriver.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pullCurrentBranch", + cwd, + args: ["pull", "--ff-only"], + }), + detail: "Current branch has no upstream configured. Push with upstream first.", + }); } const beforeSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.beforeSha", @@ -1691,7 +1746,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => stdout.trim())); yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", + fallbackErrorDetail: "git pull failed", }); const afterSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.afterSha", @@ -1708,7 +1763,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn( + const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { const range = `${baseRef}..HEAD`; @@ -1815,7 +1870,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const dirtyTrackedResult = yield* executeGit( "GitVcsDriver.getReviewDiffPreview.dirtyTracked", input.cwd, - ["diff", "--patch", "--minimal", "HEAD", "--"], + [ + "diff", + "--patch", + "--minimal", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + "HEAD", + "--", + ], { maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -1841,7 +1903,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? yield* executeGit( "GitVcsDriver.getReviewDiffPreview.base", input.cwd, - ["diff", "--patch", "--minimal", `${baseRef}...HEAD`], + [ + "diff", + "--patch", + "--minimal", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + `${baseRef}...HEAD`, + ], { maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -1861,14 +1929,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe( Effect.map(Encoding.encodeHex), Effect.mapError( - toGitCommandError( - { + (cause) => + new GitCommandError({ operation: "GitVcsDriver.getReviewDiffPreview.hash", + command: "crypto.digest SHA-256", cwd: input.cwd, - args: [], - }, - "failed to hash review diff.", - ), + detail: "Failed to hash review diff.", + cause, + }), ), ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ @@ -1906,13 +1974,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => + const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) => runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( Effect.map((stdout) => stdout.trim()), Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")( + const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( function* (input) { const branchRecencyPromise = readBranchRecency(input.cwd).pipe( Effect.orElseSucceed(() => new Map()), @@ -1926,20 +1994,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catchIf(isMissingGitCwdError, () => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(128), - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) + ? Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(128), + stdout: "", + stderr: "fatal: not a git repository", + stdoutTruncated: false, + stderrTruncated: false, + }) + : Effect.fail(error), + }), ); if (localBranchResult.exitCode !== 0) { const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { + if (isNonRepositoryGitStderr(stderr)) { return { refs: [], isRepo: false, @@ -1948,12 +2019,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* totalCount: 0, }; } - return yield* createGitCommandError( - "GitVcsDriver.listRefs", - input.cwd, - ["branch", "--no-color", "--no-column"], - stderr || "git branch failed", - ); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.listRefs", + cwd: input.cwd, + args: ["branch", "--no-color", "--no-column"], + }), + detail: "Git branch listing failed.", + exitCode: localBranchResult.exitCode, + stdoutLength: localBranchResult.stdout.length, + stderrLength: localBranchResult.stderr.length, + }); } const remoteBranchResultEffect = executeGit( @@ -1965,19 +2041,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - ), + Effect.catchTags({ + GitCommandError: (error) => + Effect.logWarning( + "Git remote ref lookup failed; falling back to an empty remote ref list.", + { + operation: error.operation, + command: error.command, + cwd: error.cwd, + detail: error.detail, + cause: error, + }, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + }), ); const remoteNamesResultEffect = executeGit( @@ -1989,19 +2073,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - ), + Effect.catchTags({ + GitCommandError: (error) => + Effect.logWarning( + "Git remote name lookup failed; falling back to an empty remote name list.", + { + operation: error.operation, + command: error.command, + cwd: error.cwd, + detail: error.detail, + cause: error, + }, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + }), ); const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = @@ -2125,11 +2217,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }) : []; + const allBranches = input.includeMatchingRemoteRefs + ? [...localBranches, ...remoteBranches] + : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); + const branchesForKind = + input.refKind === "local" + ? allBranches.filter((ref) => !ref.isRemote) + : input.refKind === "remote" + ? allBranches.filter((ref) => ref.isRemote) + : allBranches; const refs = paginateBranches({ - refs: filterBranchesForListQuery( - dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), - input.query, - ), + refs: filterBranchesForListQuery(branchesForKind, input.query), cursor: input.cursor, limit: input.limit, }); @@ -2144,7 +2242,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const createWorktree: GitVcsDriver.GitVcsDriverShape["createWorktree"] = Effect.fn( + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { const targetBranch = input.newRefName ?? input.refName; @@ -2156,9 +2254,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : ["worktree", "add", worktreePath, input.refName]; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", + fallbackErrorDetail: "git worktree add failed", }); + if (input.newRefName && input.baseRefName) { + const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); + const parsedBaseRef = parseRemoteRefWithRemoteNames( + input.baseRefName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; + yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ + "config", + `branch.${input.newRefName}.gh-merge-base`, + baseBranch, + ]); + } + return { worktree: { path: worktreePath, @@ -2167,7 +2279,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] = + const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = Effect.fn("fetchPullRequestBranch")(function* (input) { const remoteName = yield* resolvePrimaryRemoteName(input.cwd); yield* executeGit( @@ -2181,12 +2293,44 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, ], { - fallbackErrorMessage: "git fetch pull request branch failed", + fallbackErrorDetail: "git fetch pull request branch failed", }, ); }); - const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( + const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( + function* (input) { + yield* executeGit( + "GitVcsDriver.fetchRemote", + input.cwd, + ["fetch", "--quiet", input.remoteName], + { + env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: `git fetch ${input.remoteName} failed`, + }, + ); + }, + ); + + const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] = + Effect.fn("resolveRemoteTrackingCommit")(function* (input) { + const remoteNames = yield* listRemoteNames(input.cwd); + const parsedRemoteRef = parseRemoteRefWithRemoteNames( + input.refName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const remoteRefName = + parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`; + const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [ + "rev-parse", + "--verify", + `refs/remotes/${remoteRefName}^{commit}`, + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha, remoteRefName }; + }); + + const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ @@ -2208,7 +2352,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] = + const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = Effect.fn("fetchRemoteTrackingBranch")(function* (input) { yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ "fetch", @@ -2219,7 +2363,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]); }); - const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) => + const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ "branch", "--set-upstream-to", @@ -2227,7 +2371,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.branch, ]); - const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn( + const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { const args = ["worktree", "remove"]; @@ -2237,42 +2381,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args.push(input.path); yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitVcsDriver.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, - error, - ), - ), - ); + fallbackErrorDetail: "git worktree remove failed", + }); }); - const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( - function* (input) { - if (input.oldBranch === input.newBranch) { - return { branch: input.newBranch }; - } - const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( + "renameBranch", + )(function* (input) { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); - yield* executeGit( - "GitVcsDriver.renameBranch", - input.cwd, - ["branch", "-m", "--", input.oldBranch, targetBranch], - { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", - }, - ); + yield* executeGit( + "GitVcsDriver.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorDetail: "git branch rename failed", + }, + ); - return { branch: targetBranch }; - }, - ); + return { branch: targetBranch }; + }); - const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")( + const switchRef: GitVcsDriver.GitVcsDriver["Service"]["switchRef"] = Effect.fn("switchRef")( function* (input) { const [localInputExists, remoteExists] = yield* Effect.all( [ @@ -2342,7 +2476,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", + fallbackErrorDetail: "git checkout failed", }); const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ @@ -2354,11 +2488,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")( + const createRef: GitVcsDriver.GitVcsDriver["Service"]["createRef"] = Effect.fn("createRef")( function* (input) { yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", + fallbackErrorDetail: "git branch create failed", }); if (input.switchRef) { yield* switchRef({ cwd: input.cwd, refName: input.refName }); @@ -2368,13 +2502,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => + const initRepo: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, - fallbackErrorMessage: "git init failed", + fallbackErrorDetail: "git init failed", }).pipe(Effect.asVoid); - const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) => + const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = ( + cwd, + ) => runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ "branch", "--list", @@ -2411,6 +2547,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fetchPullRequestBranch, ensureRemote, resolvePrimaryRemoteName, + fetchRemote, + resolveRemoteTrackingCommit, fetchRemoteBranch, fetchRemoteTrackingBranch, setBranchUpstream, diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index 1885a49ce92..f2daf793502 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -52,26 +52,29 @@ export interface VcsCheckpointOps { ) => Effect.Effect; } -export interface VcsDriverShape { - readonly capabilities: VcsDriverCapabilities; - readonly execute: ( - input: Omit, - ) => Effect.Effect; - readonly checkpoints?: VcsCheckpointOps; - readonly detectRepository: (cwd: string) => Effect.Effect; - readonly isInsideWorkTree: (cwd: string) => Effect.Effect; - readonly listWorkspaceFiles: ( - cwd: string, - ) => Effect.Effect; - readonly listRemotes: (cwd: string) => Effect.Effect; - readonly filterIgnoredPaths: ( - cwd: string, - relativePaths: ReadonlyArray, - ) => Effect.Effect, VcsError>; - readonly initRepository: (input: VcsInitInput) => Effect.Effect; - readonly getDiffPreview?: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; -} - -export class VcsDriver extends Context.Service()("t3/vcs/VcsDriver") {} +export class VcsDriver extends Context.Service< + VcsDriver, + { + readonly capabilities: VcsDriverCapabilities; + readonly execute: ( + input: Omit, + ) => Effect.Effect; + readonly checkpoints?: VcsCheckpointOps; + readonly detectRepository: ( + cwd: string, + ) => Effect.Effect; + readonly isInsideWorkTree: (cwd: string) => Effect.Effect; + readonly listWorkspaceFiles: ( + cwd: string, + ) => Effect.Effect; + readonly listRemotes: (cwd: string) => Effect.Effect; + readonly filterIgnoredPaths: ( + cwd: string, + relativePaths: ReadonlyArray, + ) => Effect.Effect, VcsError>; + readonly initRepository: (input: VcsInitInput) => Effect.Effect; + readonly getDiffPreview?: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + } +>()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 03c09c16be8..7a531a5adcc 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -21,7 +21,7 @@ const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => describe("VcsDriverRegistry", () => { it.effect("routes directly by VCS driver kind for non-repository workflows", () => { - const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), Layer.provide( Layer.mock(VcsProjectConfig.VcsProjectConfig)({ @@ -45,7 +45,7 @@ describe("VcsDriverRegistry", () => { it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { const calls: VcsProcess.VcsProcessInput[] = []; - const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), Layer.provide( Layer.mock(VcsProjectConfig.VcsProjectConfig)({ diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 22868855737..0bf95c2ffba 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -22,27 +22,19 @@ export interface VcsDriverResolveInput { export interface VcsDriverHandle { readonly kind: VcsDriverKind; readonly repository: VcsRepositoryIdentity; - readonly driver: VcsDriver.VcsDriverShape; + readonly driver: VcsDriver.VcsDriver["Service"]; } -export interface VcsDriverRegistryShape { - readonly get: (kind: VcsDriverKind) => Effect.Effect; - readonly detect: ( - input: VcsDriverResolveInput, - ) => Effect.Effect; - readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect; -} - -export class VcsDriverRegistry extends Context.Service()( - "t3/vcs/VcsDriverRegistry", -) {} - -const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => - new VcsUnsupportedOperationError({ - operation, - kind, - detail, - }); +export class VcsDriverRegistry extends Context.Service< + VcsDriverRegistry, + { + readonly get: (kind: VcsDriverKind) => Effect.Effect; + readonly detect: ( + input: VcsDriverResolveInput, + ) => Effect.Effect; + readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect; + } +>()("t3/vcs/VcsDriverRegistry") {} function detectionCacheKey(input: { readonly cwd: string; @@ -68,18 +60,22 @@ function parseDetectionCacheKey(key: string): { }; } -export const make = Effect.fn("makeVcsDriverRegistry")(function* () { +export const make = Effect.gen(function* () { const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; - const git = yield* GitVcsDriver.makeVcsDriverShape(); - const drivers: Partial> = { + const git = yield* GitVcsDriver.makeVcsDriver; + const drivers: Partial> = { git, }; - const get: VcsDriverRegistryShape["get"] = (kind) => { + const get: VcsDriverRegistry["Service"]["get"] = (kind) => { const driver = drivers[kind]; if (!driver) { return Effect.fail( - unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`), + new VcsUnsupportedOperationError({ + operation: "VcsDriverRegistry.get", + kind, + detail: `No ${kind} VCS driver is registered.`, + }), ); } return Effect.succeed(driver); @@ -87,7 +83,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( kind: VcsDriverKind, - driver: VcsDriver.VcsDriverShape, + driver: VcsDriver.VcsDriver["Service"], cwd: string, ) { const repository = yield* driver.detectRepository(cwd); @@ -123,14 +119,14 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { }, ); - const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")( + const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( function* (input) { const requestedKind = yield* projectConfig.resolveKind(input); return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); }, ); - const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( + const resolve: VcsDriverRegistry["Service"]["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( function* (input) { const detected = yield* detect(input); if (detected) { @@ -138,13 +134,14 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { } const requestedKind = input.requestedKind ?? "auto"; - return yield* unsupported( - "VcsDriverRegistry.resolve", - requestedKind === "auto" ? "unknown" : requestedKind, - requestedKind === "auto" - ? `No supported VCS repository was detected at ${input.cwd}.` - : `No ${requestedKind} repository was detected at ${input.cwd}.`, - ); + return yield* new VcsUnsupportedOperationError({ + operation: "VcsDriverRegistry.resolve", + kind: requestedKind === "auto" ? "unknown" : requestedKind, + detail: + requestedKind === "auto" + ? `No supported VCS repository was detected at ${input.cwd}.` + : `No ${requestedKind} repository was detected at ${input.cwd}.`, + }); }, ); @@ -155,6 +152,6 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { }); }); -export const layer = Layer.effect(VcsDriverRegistry, make()).pipe( +export const layer = Layer.effect(VcsDriverRegistry, make).pipe( Layer.provide(VcsProjectConfig.layer), ); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index b58d64e435a..675d20cb82c 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -6,7 +6,12 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { TestClock } from "effect/testing"; -import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; +import { + VcsProcessExitError, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; +import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; const run = (input: VcsProcess.VcsProcessInput) => @@ -20,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const provideLive = (effect: Effect.Effect) => effect.pipe(Effect.provide(liveLayer)); +const baseInput = { + operation: "test.process-boundary", + command: "git", + args: ["status", "--short"], + cwd: "/workspace", +} satisfies VcsProcess.VcsProcessInput; + +const captureProcessResult = ( + result: Effect.Effect, +) => + VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: () => result }), + ), + Effect.flatMap((service) => service.run(baseInput)), + Effect.flip, + ); + describe("VcsProcess.run", () => { it.effect("collects stdout", () => Effect.gen(function* () { @@ -61,17 +85,127 @@ describe("VcsProcess.run", () => { it.effect("fails with VcsProcessExitError for non-zero exits by default", () => Effect.gen(function* () { + const secretArgument = "--token=super-secret-token"; + const secretStderr = "remote rejected super-secret-token"; const error = yield* run({ operation: "test.exit", command: "node", - args: ["-e", "process.stderr.write('boom'); process.exit(2)"], + args: [ + "-e", + "process.stderr.write(process.argv[1]); process.exit(2)", + secretStderr, + secretArgument, + ], cwd: process.cwd(), }).pipe(Effect.flip); expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + operation: "test.exit", + command: "node", + argumentCount: 4, + exitCode: 2, + detail: "Process exited with a non-zero status.", + failureKind: "command-failed", + stderrLength: secretStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(secretArgument); + expect(error.message).not.toContain(secretStderr); }).pipe(provideLive), ); + it.effect("classifies authentication failures without retaining stderr", () => + Effect.gen(function* () { + const secretStderr = "authentication failed for token super-secret-token"; + const error = yield* run({ + operation: "test.authentication", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + operation: "test.authentication", + command: "node", + exitCode: 1, + detail: "Authentication failed.", + failureKind: "authentication", + stderrLength: secretStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(secretStderr); + expect(error.message).not.toContain("super-secret-token"); + }).pipe(provideLive), + ); + + it.effect("retains spawn causes without exposing process arguments in the error message", () => + Effect.gen(function* () { + const secretArgument = "--token=super-secret-token"; + const error = yield* run({ + operation: "test.spawn", + command: "definitely-not-a-t3code-executable", + args: [secretArgument], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessSpawnError); + expect(error).toMatchObject({ + operation: "test.spawn", + command: "definitely-not-a-t3code-executable", + argumentCount: 1, + }); + expect(error).toHaveProperty("cause"); + expect(error.message).not.toContain(secretArgument); + }).pipe(provideLive), + ); + + it.effect("preserves real boundary causes without manufacturing structural ones", () => + Effect.gen(function* () { + const cause = new Error("secret stdin failure"); + const error = yield* captureProcessResult( + Effect.fail( + new ProcessRunner.ProcessStdinError({ + command: baseInput.command, + argumentCount: baseInput.args.length, + cwd: baseInput.cwd, + stdinBytes: 47, + cause, + }), + ), + ); + + expect(error).toMatchObject({ + _tag: "VcsProcessStdinWriteError", + operation: baseInput.operation, + stdinBytes: 47, + cause, + }); + expect(error.message).not.toContain(cause.message); + + const missingExitCodeError = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + expect(missingExitCodeError).toMatchObject({ + _tag: "VcsProcessMissingExitCodeError", + operation: baseInput.operation, + command: baseInput.command, + cwd: baseInput.cwd, + argumentCount: baseInput.args.length, + }); + expect(missingExitCodeError).not.toHaveProperty("cause"); + }), + ); + it.effect("returns output when non-zero exits are allowed", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index a4caf7d3230..52db6f9b1fb 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -1,17 +1,21 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - VcsOutputDecodeError, type VcsError, VcsProcessExitError, + type VcsProcessExitFailureKind, + VcsProcessMissingExitCodeError, + VcsProcessOutputLimitError, + VcsProcessOutputReadError, VcsProcessSpawnError, + VcsProcessStdinWriteError, VcsProcessTimeoutError, } from "@t3tools/contracts"; -import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts"; -import * as Match from "effect/Match"; +import * as ProcessRunner from "../processRunner.ts"; export interface VcsProcessInput { readonly operation: string; @@ -35,31 +39,62 @@ export interface VcsProcessOutput { readonly stderrTruncated: boolean; } -export interface VcsProcessShape { - readonly run: (input: VcsProcessInput) => Effect.Effect; -} - -export class VcsProcess extends Context.Service()( - "t3/vcs/VcsProcess", -) {} +export class VcsProcess extends Context.Service< + VcsProcess, + { + readonly run: (input: VcsProcessInput) => Effect.Effect; + } +>()("t3/vcs/VcsProcess") {} const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -function commandLabel(command: string, args: ReadonlyArray): string { - return [command, ...args].join(" "); -} +const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { + const normalized = stderr.toLowerCase(); -export const make = Effect.fn("makeVcsProcess")(function* () { - const processRunner = yield* ProcessRunner; + if ( + normalized.includes("authentication failed") || + normalized.includes("not logged in") || + normalized.includes("gh auth login") || + normalized.includes("glab auth login") || + normalized.includes("az devops login") || + normalized.includes("please run az login") || + normalized.includes("no oauth token") || + normalized.includes("unauthorized") + ) { + return "authentication"; + } + + if ( + (command === "gh" && + (normalized.includes("could not resolve to a pullrequest") || + normalized.includes("repository.pullrequest") || + normalized.includes("no pull requests found for branch") || + normalized.includes("pull request not found"))) || + (command === "glab" && + (normalized.includes("merge request not found") || + normalized.includes("not found") || + normalized.includes("404"))) || + (command === "az" && + normalized.includes("pull request") && + (normalized.includes("not found") || normalized.includes("does not exist"))) + ) { + return "not-found"; + } + + return "command-failed"; +}; + +export const make = Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { - const label = commandLabel(input.command, input.args); const baseError = { operation: input.operation, - command: label, + command: input.command, cwd: input.cwd, + argumentCount: input.args.length, }; const result = yield* processRunner @@ -82,29 +117,44 @@ export const make = Effect.fn("makeVcsProcess")(function* () { ProcessSpawnError: (error) => VcsProcessSpawnError.fromProcessSpawnError(baseError, error), ProcessOutputLimitError: (error) => - VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), + new VcsProcessOutputLimitError({ + ...baseError, + stream: error.stream, + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }), ProcessTimeoutError: (error) => VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), ProcessStdinError: (error) => - VcsOutputDecodeError.fromProcessStdinError(baseError, error), + new VcsProcessStdinWriteError({ + ...baseError, + stdinBytes: error.stdinBytes, + cause: error.cause, + }), ProcessReadError: (error) => - VcsOutputDecodeError.fromProcessReadError(baseError, error), + new VcsProcessOutputReadError({ + ...baseError, + stream: error.stream, + cause: error.cause, + }), }), ), ); if (result.code === null) { - return yield* VcsOutputDecodeError.missingExitCode(baseError); + return yield* new VcsProcessMissingExitCodeError(baseError); } if (!input.allowNonZeroExit && result.code !== 0) { - return yield* new VcsProcessExitError({ - operation: input.operation, - command: label, - cwd: input.cwd, - exitCode: result.code, - detail: result.stderr.trim() || `${label} exited with code ${result.code}.`, - }); + return yield* VcsProcessExitError.fromProcessExit( + baseError, + { + exitCode: result.code, + stderr: result.stderr, + stderrTruncated: result.stderrTruncated, + }, + classifyNonZeroExit(input.command, result.stderr), + ); } return { @@ -119,4 +169,4 @@ export const make = Effect.fn("makeVcsProcess")(function* () { return VcsProcess.of({ run }); }); -export const layer = Layer.effect(VcsProcess, make()).pipe(Layer.provide(ProcessRunnerLive)); +export const layer = Layer.effect(VcsProcess, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index aac4beb7e32..04f7fcffcda 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; 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 Path from "effect/Path"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; @@ -13,6 +14,22 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { + it("keeps operation context and the original cause on config errors", () => { + const cause = new Error("permission denied"); + const error = new VcsProjectConfig.VcsProjectConfigError({ + operation: "read", + cwd: "/repo/packages/app", + configPath: "/repo/.t3code/vcs.json", + cause, + }); + + assert.equal(error.operation, "read"); + assert.equal(error.cwd, "/repo/packages/app"); + assert.equal(error.configPath, "/repo/.t3code/vcs.json"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); + }); + it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { @@ -53,6 +70,49 @@ describe("VcsProjectConfig", () => { ); }); + it.layer(TestLayer)("continues to parent configs after a candidate inspect failure", (it) => { + it.effect("logs the failed candidate and returns the parent config", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + const cwd = path.join(root, "invalid\0child"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ vcs: { kind: "jj" } }), + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd }); + + assert.equal(kind, "jj"); + const failedCandidate = path.join(cwd, ".t3code", "vcs.json"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal( + error.message, + "Failed to inspect VCS project config at " + failedCandidate + ".", + ); + assert.deepInclude(error, { + operation: "inspect", + cwd, + configPath: failedCandidate, + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + it.layer(TestLayer)("falls back to auto when no config exists", (it) => { it.effect("returns auto", () => Effect.gen(function* () { @@ -67,4 +127,99 @@ describe("VcsProjectConfig", () => { }), ); }); + + it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => { + it.effect("returns auto and logs the failed operation and path", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{not json"); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal( + error.message, + "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".", + ); + assert.deepInclude(error.cause, { _tag: "SchemaError" }); + assert.deepInclude(error, { + operation: "decode", + cwd: root, + configPath: path.join(configDir, "vcs.json"), + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + + it.layer(TestLayer)("falls back to auto when the config path cannot be read", (it) => { + it.effect("retains the read failure context", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configPath = path.join(root, ".t3code", "vcs.json"); + yield* fileSystem.makeDirectory(configPath, { recursive: true }); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal(error.message, "Failed to read VCS project config at " + configPath + "."); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + assert.deepInclude(error, { + operation: "read", + cwd: root, + configPath, + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + + it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => { + it.effect("returns auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + `{"vcs":{"kind":"svn"}}`, + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 3e5ee2347ce..6abce9a3ef3 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -2,10 +2,12 @@ import * as Context from "effect/Context"; 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"; import * as Schema from "effect/Schema"; import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; const ProjectVcsConfig = Schema.Struct({ vcs: Schema.optional( @@ -15,46 +17,54 @@ const ProjectVcsConfig = Schema.Struct({ ), vcsKind: Schema.optional(VcsDriverKind), }); -const isProjectVcsConfig = Schema.is(ProjectVcsConfig); +const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig); +const decodeProjectVcsConfigJson = Schema.decodeUnknownEffect(ProjectVcsConfigJson); -interface ProjectVcsConfigFile { - readonly vcs?: - | { - readonly kind?: VcsDriverKindType | undefined; - } - | undefined; - readonly vcsKind?: VcsDriverKindType | undefined; -} +type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; export interface VcsProjectConfigResolveInput { readonly cwd: string; readonly requestedKind?: VcsDriverKindType | "auto"; } -export interface VcsProjectConfigShape { - readonly resolveKind: ( - input: VcsProjectConfigResolveInput, - ) => Effect.Effect; +export class VcsProjectConfigError extends Schema.TaggedErrorClass()( + "VcsProjectConfigError", + { + operation: Schema.Literals(["inspect", "read", "decode"]), + cwd: Schema.String, + configPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} VCS project config at ${this.configPath}.`; + } } -export class VcsProjectConfig extends Context.Service()( - "t3/vcs/VcsProjectConfig", -) {} +export class VcsProjectConfig extends Context.Service< + VcsProjectConfig, + { + readonly resolveKind: ( + input: VcsProjectConfigResolveInput, + ) => Effect.Effect; + } +>()("t3/vcs/VcsProjectConfig") {} function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" { return config.vcs?.kind ?? config.vcsKind ?? "auto"; } -function parseConfig(raw: string): ProjectVcsConfigFile | null { - try { - const parsed = JSON.parse(raw) as unknown; - return isProjectVcsConfig(parsed) ? parsed : null; - } catch { - return null; - } -} +const logVcsProjectConfigError = (error: VcsProjectConfigError) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + operation: error.operation, + cwd: error.cwd, + configPath: error.configPath, + errorTag: error._tag, + }), + ); -export const make = Effect.fn("makeVcsProjectConfig")(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -62,57 +72,80 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { let current = cwd; while (true) { const candidate = path.join(current, ".t3code", "vcs.json"); - if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - return candidate; + const exists = yield* fileSystem.exists(candidate).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "inspect", + cwd, + configPath: candidate, + cause, + }), + ), + Effect.catchTags({ + VcsProjectConfigError: (error) => logVcsProjectConfigError(error).pipe(Effect.as(false)), + }), + ); + if (exists) { + return Option.some(candidate); } const parent = path.dirname(current); if (parent === current) { - return null; + return Option.none(); } current = parent; } }); const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* ( + cwd: string, configPath: string, ) { const raw = yield* fileSystem.readFileString(configPath).pipe( - Effect.catch((error) => - Effect.logWarning("failed to read VCS project config", { - configPath, - error, - }).pipe(Effect.as(null)), + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "read", + cwd, + configPath, + cause, + }), + ), + ); + const parsed = yield* decodeProjectVcsConfigJson(raw).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "decode", + cwd, + configPath, + cause, + }), ), ); - if (raw === null) { - return "auto" as const; - } - - const parsed = parseConfig(raw); - if (parsed === null) { - yield* Effect.logWarning("invalid VCS project config", { - configPath, - }); - return "auto" as const; - } - return configuredKind(parsed); }); - const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( + const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn( "VcsProjectConfig.resolveKind", )(function* (input) { if (input.requestedKind !== undefined && input.requestedKind !== "auto") { return input.requestedKind; } - const configPath = yield* findConfigPath(input.cwd); - if (configPath === null) { - return "auto"; - } - - return yield* readConfiguredKind(configPath); + return yield* findConfigPath(input.cwd).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed("auto" as const), + onSome: (configPath) => readConfiguredKind(input.cwd, configPath), + }), + ), + Effect.catchTags({ + VcsProjectConfigError: (error) => + logVcsProjectConfigError(error).pipe(Effect.as("auto" as const)), + }), + ); }); return VcsProjectConfig.of({ @@ -120,4 +153,4 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { }); }); -export const layer = Layer.effect(VcsProjectConfig, make()); +export const layer = Layer.effect(VcsProjectConfig, make); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts index ba919a5f435..0a28f9c9b2c 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -11,7 +11,7 @@ import * as VcsProvisioningService from "./VcsProvisioningService.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -function makeDriver(calls: string[]): VcsDriver.VcsDriverShape { +function makeDriver(calls: string[]): VcsDriver.VcsDriver["Service"] { return { capabilities: { kind: "git", diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index 38006b4b603..9febacf2256 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -10,13 +10,11 @@ import { } from "@t3tools/contracts"; import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; -export interface VcsProvisioningServiceShape { - readonly initRepository: (input: VcsInitInput) => Effect.Effect; -} - export class VcsProvisioningService extends Context.Service< VcsProvisioningService, - VcsProvisioningServiceShape + { + readonly initRepository: (input: VcsInitInput) => Effect.Effect; + } >()("t3/vcs/VcsProvisioningService") {} function resolveRequestedKind( @@ -37,10 +35,10 @@ function resolveRequestedKind( return Effect.succeed(kind); } -export const make = Effect.fn("makeVcsProvisioningService")(function* () { +export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( + const initRepository: VcsProvisioningService["Service"]["initRepository"] = Effect.fn( "VcsProvisioningService.initRepository", )(function* (input) { const kind = yield* resolveRequestedKind(input.kind); @@ -53,4 +51,4 @@ export const make = Effect.fn("makeVcsProvisioningService")(function* () { }); }); -export const layer = Layer.effect(VcsProvisioningService, make()); +export const layer = Layer.effect(VcsProvisioningService, make); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 7c5768162a9..032e48e4612 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,11 +1,13 @@ import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; 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 Scope from "effect/Scope"; @@ -43,6 +45,18 @@ const baseRemoteStatus: VcsStatusRemoteResult = { pr: null, }; +const remoteStatusWithPr: VcsStatusRemoteResult = { + ...baseRemoteStatus, + pr: { + number: 2978, + title: "[codex] Rewrite client connection architecture", + url: "https://github.com/pingdotgg/t3code/pull/2978", + baseRef: "main", + headRef: "codex/connection-state-audit", + state: "open", + }, +}; + const baseStatus: VcsStatusResult = { ...baseLocalStatus, ...baseRemoteStatus, @@ -55,6 +69,7 @@ function makeTestLayer(state: { remoteStatusCalls: number; localInvalidationCalls: number; remoteInvalidationCalls: number; + remoteStatusRefreshUpstreamValues?: Array; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -65,9 +80,10 @@ function makeTestLayer(state: { state.localStatusCalls += 1; return state.currentLocalStatus; }), - remoteStatus: () => + remoteStatus: (_input, options) => Effect.sync(() => { state.remoteStatusCalls += 1; + state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); return state.currentRemoteStatus; }), invalidateLocalStatus: () => @@ -176,6 +192,7 @@ describe("VcsStatusBroadcaster", () => { ? Effect.fail( new GitManagerError({ operation: "VcsStatusBroadcaster.test", + cwd: "/repo", detail: "remote status failed", }), ) @@ -285,7 +302,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -352,29 +369,180 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); - it.effect("does not start automatic remote refreshes when disabled", () => { + it.effect("loads remote status once when periodic refreshes are disabled", () => { const state = { currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, + currentRemoteStatus: remoteStatusWithPr, localStatusCalls: 0, remoteStatusCalls: 0, localInvalidationCalls: 0, remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [] as Array, }; return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const snapshot = yield* Stream.runHead( + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach( broadcaster.streamStatus( { cwd: "/repo" }, { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, ), + (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }, + ).pipe(Effect.forkIn(scope)); + + const snapshot = yield* Deferred.await(snapshotDeferred); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies VcsStatusStreamEvent); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: remoteStatusWithPr, + } satisfies VcsStatusStreamEvent); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false]); + + yield* TestClock.adjust(Duration.minutes(2)); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + + it.effect("retries the initial remote load when periodic refreshes are disabled", () => { + const state = { + currentLocalStatus: baseLocalStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [] as Array, + }; + const privateCwd = "/private/user/workspace/repo"; + const nestedCause = new Error("private nested VCS failure"); + const messages: Array> = []; + const logger = Logger.make(({ message }) => { + messages.push(message as ReadonlyArray); + }); + let firstRemoteAttemptDeferred: Deferred.Deferred | null = null; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (_input, options) => + Effect.suspend(() => { + state.remoteStatusCalls += 1; + state.remoteStatusRefreshUpstreamValues.push(options?.refreshUpstream); + if (state.remoteStatusCalls === 1) { + return Effect.fail( + new GitManagerError({ + operation: "VcsStatusBroadcaster.test", + cwd: privateCwd, + detail: "private initial remote status failure", + cause: nestedCause, + }), + ).pipe( + Effect.ensuring( + firstRemoteAttemptDeferred + ? Deferred.succeed(firstRemoteAttemptDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ); + } + return Effect.succeed(remoteStatusWithPr); + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const scope = yield* Scope.make(); + firstRemoteAttemptDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: privateCwd }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + (event) => + event._tag === "remoteUpdated" + ? Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(firstRemoteAttemptDeferred); + yield* Effect.yieldNow; + assert.equal(state.remoteStatusCalls, 1); + assert.deepStrictEqual( + messages.find((message) => message[0] === "VCS remote status refresh failed"), + [ + "VCS remote status refresh failed", + { + cwdLength: privateCwd.length, + reasonCount: 1, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.test"], + defectCount: 0, + defectTags: [], + interruptionCount: 0, + consecutiveFailures: 1, + nextDelayMs: 30_000, + }, + ], ); - assert.isTrue(Option.isSome(snapshot)); - assert.equal(state.remoteStatusCalls, 0); + yield* TestClock.adjust(Duration.seconds(30)); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: remoteStatusWithPr, + } satisfies VcsStatusStreamEvent); + assert.equal(state.remoteStatusCalls, 2); assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(makeTestLayer(state))); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); + + yield* Scope.close(scope, Exit.void); + }).pipe( + Effect.provide( + Layer.mergeAll( + testLayer, + TestClock.layer(), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); }); it.effect("delays automatic refresh when a cached remote snapshot is available", () => { @@ -442,6 +610,27 @@ describe("VcsStatusBroadcaster", () => { ); }); + it("summarizes refresh causes without exposing nested failure details", () => { + const nestedCause = new Error("private nested failure detail"); + const failure = new GitManagerError({ + operation: "VcsStatusBroadcaster.remoteStatus", + cwd: "/private/user/workspace/repo", + detail: "private Git failure detail", + cause: nestedCause, + }); + const cause = Cause.combine(Cause.fail(failure), Cause.die(new TypeError("private defect"))); + + assert.deepStrictEqual(VcsStatusBroadcaster.remoteRefreshFailureDiagnostics(cause), { + reasonCount: 2, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.remoteStatus"], + defectCount: 1, + defectTags: ["TypeError"], + interruptionCount: 0, + }); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -486,7 +675,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d83dc26fbed..c238154f58c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -26,6 +27,91 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); +const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; +const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; + +function boundedDiagnosticValue(value: string): string { + return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); +} + +function diagnosticValueTag(value: unknown): string { + try { + if ( + typeof value === "object" && + value !== null && + "_tag" in value && + typeof value._tag === "string" + ) { + return boundedDiagnosticValue(value._tag); + } + if (value instanceof Error) { + return boundedDiagnosticValue(value.name); + } + return typeof value; + } catch { + return "Uninspectable"; + } +} + +function diagnosticFailureOperation(value: unknown): string | undefined { + try { + if ( + typeof value === "object" && + value !== null && + "operation" in value && + typeof value.operation === "string" + ) { + return boundedDiagnosticValue(value.operation); + } + } catch { + return undefined; + } + return undefined; +} + +function addUniqueDiagnosticValue(values: Array, value: string | undefined): void { + if ( + value !== undefined && + values.length < MAX_FAILURE_DIAGNOSTIC_VALUES && + !values.includes(value) + ) { + values.push(value); + } +} + +export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { + const failureTags: Array = []; + const failureOperations: Array = []; + const defectTags: Array = []; + let failureCount = 0; + let defectCount = 0; + let interruptionCount = 0; + + for (const reason of cause.reasons) { + if (Cause.isFailReason(reason)) { + failureCount += 1; + addUniqueDiagnosticValue(failureTags, diagnosticValueTag(reason.error)); + addUniqueDiagnosticValue(failureOperations, diagnosticFailureOperation(reason.error)); + continue; + } + if (Cause.isDieReason(reason)) { + defectCount += 1; + addUniqueDiagnosticValue(defectTags, diagnosticValueTag(reason.defect)); + continue; + } + interruptionCount += 1; + } + + return { + reasonCount: cause.reasons.length, + failureCount, + failureTags, + failureOperations, + defectCount, + defectTags, + interruptionCount, + }; +} interface VcsStatusChange { readonly cwd: string; @@ -65,23 +151,21 @@ export function remoteRefreshFailureDelay( return Duration.max(configuredInterval, cappedBackoff); } -export interface VcsStatusBroadcasterShape { - readonly getStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly refreshLocalStatus: ( - cwd: string, - ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; - readonly streamStatus: ( - input: VcsStatusInput, - options?: StreamStatusOptions, - ) => Stream.Stream; -} - export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, - VcsStatusBroadcasterShape + { + readonly getStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: VcsStatusInput, + options?: StreamStatusOptions, + ) => Stream.Stream; + } >()("t3/vcs/VcsStatusBroadcaster") {} function fingerprintStatusPart(status: unknown): string { @@ -94,101 +178,57 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); -export const layer = Layer.effect( - VcsStatusBroadcaster, - Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const fs = yield* FileSystem.FileSystem; - const changesPubSub = yield* Effect.acquireRelease( - PubSub.unbounded(), - (pubsub) => PubSub.shutdown(pubsub), - ); - const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const cacheRef = yield* Ref.make(new Map()); - const pollersRef = yield* SynchronizedRef.make(new Map()); +export const make = Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const fs = yield* FileSystem.FileSystem; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); - const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( - cwd: string, - ) { - return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); - }); + const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( + cwd: string, + ) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); - const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( - function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - local: nextLocal, - }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( + function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "localUpdated", - local, - }, - }); - } - - return local; - }, - ); - - const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( - function* ( - cwd: string, - remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - remote: nextRemote, - }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, }); + } - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "remoteUpdated", - remote, - }, - }); - } - - return remote; - }, - ); + return local; + }, + ); - const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( - cwd: string, - local: VcsStatusLocalResult, - remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; + const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( + function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, @@ -197,255 +237,307 @@ export const layer = Layer.effect( const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { - local: nextLocal, + ...previous, remote: nextRemote, }); - return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, - nextCache, - ] as const; + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; }); if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, event: { - _tag: "snapshot", - local, + _tag: "remoteUpdated", remote, }, }); } - return mergeGitStatusParts(local, remote); - }); + return remote; + }, + ); - const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( - cwd: string, - ) { - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); + const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( + cwd: string, + local: VcsStatusLocalResult, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + local: nextLocal, + remote: nextRemote, + }); + return [ + previous.local?.fingerprint !== nextLocal.fingerprint || + previous.remote?.fingerprint !== nextRemote.fingerprint, + nextCache, + ] as const; }); - const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( - cwd: string, - ) { - const cached = yield* getCachedStatus(cwd); - if (cached?.local) { - return cached.local.value; - } - return yield* loadLocalStatus(cwd); - }); + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "snapshot", + local, + remote, + }, + }); + } - const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + return mergeGitStatusParts(local, remote); + }); - const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( - "VcsStatusBroadcaster.getStatus", - )(function* (input) { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const cached = yield* getCachedStatus(cwd); - if (cached?.local && cached.remote) { - return mergeGitStatusParts(cached.local.value, cached.remote.value); - } - const [local, remote] = yield* Effect.all( - [ - cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), - ], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote); - }); + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + cwd: string, + ) { + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); - const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( - function* (cwd: string) { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); - }, + const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( + cwd: string, + ) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + + const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( + "VcsStatusBroadcaster.getStatus", + )(function* (input) { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const cached = yield* getCachedStatus(cwd); + if (cached?.local && cached.remote) { + return mergeGitStatusParts(cached.local.value, cached.remote.value); + } + const [local, remote] = yield* Effect.all( + [ + cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), + cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, ); + return yield* updateCachedStatus(cwd, local, remote); + }); - const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( - "VcsStatusBroadcaster.refreshLocalStatus", - )(function* (rawCwd) { - const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); - }); + const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( + function* (cwd: string) { + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }, + ); - const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( - cwd: string, - ) { + const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshLocalStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + return yield* refreshLocalStatusCore(cwd); + }); + + const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( + cwd: string, + options?: { readonly refreshUpstream?: boolean }, + ) { + if (options?.refreshUpstream !== false) { yield* workflow.invalidateRemoteStatus(cwd); - const remote = yield* workflow.remoteStatus({ cwd }); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { + concurrency: "unbounded", + discard: true, }); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + }); - const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( - "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { - const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* Effect.all( - [workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], - { concurrency: "unbounded", discard: true }, - ); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); - }); + const makeRemoteRefreshLoop = ( + cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, + ) => { + return Effect.gen(function* () { + const consecutiveFailuresRef = yield* Ref.make(0); + const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); + const refreshRemoteStatusIfEnabled = Effect.gen(function* () { + const configuredInterval = yield* automaticRemoteRefreshInterval; + const activeInterval = Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval; + const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); + if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { + return activeInterval; + } - const makeRemoteRefreshLoop = ( - cwd: string, - automaticRemoteRefreshInterval: Effect.Effect, - refreshImmediately: boolean, - ) => { - return Effect.gen(function* () { - const consecutiveFailuresRef = yield* Ref.make(0); - const refreshRemoteStatusIfEnabled = Effect.gen(function* () { - const configuredInterval = yield* automaticRemoteRefreshInterval; - const activeInterval = Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval; - if (Duration.isZero(configuredInterval)) { - return activeInterval; - } - - const exit = yield* refreshRemoteStatus(cwd).pipe(Effect.exit); - if (Exit.isSuccess(exit)) { - yield* Ref.set(consecutiveFailuresRef, 0); - return activeInterval; - } - - const consecutiveFailures = yield* Ref.updateAndGet( - consecutiveFailuresRef, - (count) => count + 1, - ); - const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); - yield* Effect.logWarning("VCS remote status refresh failed", { - cwd, - detail: exit.cause.toString(), - consecutiveFailures, - nextDelayMs: Duration.toMillis(nextDelay), - }); - return nextDelay; - }); + const exit = yield* refreshRemoteStatus(cwd, { + refreshUpstream: !Duration.isZero(configuredInterval), + }).pipe(Effect.exit); + if (Exit.isSuccess(exit)) { + yield* Ref.set(needsInitialRefreshRef, false); + yield* Ref.set(consecutiveFailuresRef, 0); + return activeInterval; + } - if (!refreshImmediately) { - const configuredInterval = yield* automaticRemoteRefreshInterval; - yield* Effect.sleep( - Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval, - ); + const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); + if (interruptionReasons.length > 0) { + return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); } - return yield* refreshRemoteStatusIfEnabled.pipe( - Effect.repeat( - Schedule.identity().pipe( - Schedule.addDelay((delay) => Effect.succeed(delay)), - ), - ), - Effect.asVoid, + const consecutiveFailures = yield* Ref.updateAndGet( + consecutiveFailuresRef, + (count) => count + 1, ); + const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); + yield* Effect.logWarning("VCS remote status refresh failed", { + cwdLength: cwd.length, + ...remoteRefreshFailureDiagnostics(exit.cause), + consecutiveFailures, + nextDelayMs: Duration.toMillis(nextDelay), + }); + return nextDelay; }); - }; - const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( - cwd: string, - automaticRemoteRefreshInterval: Effect.Effect, - refreshImmediately: boolean, - ) { - yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (existing) { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount + 1, - }); - return Effect.succeed([undefined, nextPollers] as const); - } - - return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( - Effect.forkIn(broadcasterScope), - Effect.map((fiber) => { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - fiber, - subscriberCount: 1, - }); - return [undefined, nextPollers] as const; - }), + if (!refreshImmediately) { + const configuredInterval = yield* automaticRemoteRefreshInterval; + yield* Effect.sleep( + Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval, ); - }); + } + + return yield* refreshRemoteStatusIfEnabled.pipe( + Effect.repeat( + Schedule.identity().pipe( + Schedule.addDelay((delay) => Effect.succeed(delay)), + ), + ), + Effect.asVoid, + ); }); + }; - const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( - cwd: string, - ) { - const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (!existing) { - return [null, activePollers] as const; - } + const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( + cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, + ) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } - if (existing.subscriberCount > 1) { + return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { const nextPollers = new Map(activePollers); nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount - 1, + fiber, + subscriberCount: 1, }); - return [null, nextPollers] as const; - } + return [undefined, nextPollers] as const; + }), + ); + }); + }); - const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); - return [existing.fiber, nextPollers] as const; - }); + const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( + cwd: string, + ) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [null, activePollers] as const; + } - if (pollerToInterrupt) { - yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; }); - const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) => - Stream.unwrap( - Effect.gen(function* () { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(cwd); - const cachedStatus = yield* getCachedStatus(cwd); - const initialRemote = cachedStatus?.remote?.value ?? null; - yield* retainRemotePoller( - cwd, - options?.automaticRemoteRefreshInterval ?? - Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), - cachedStatus?.remote === null || cachedStatus?.remote === undefined, - ); - - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); - - return Stream.concat( - Stream.make({ - _tag: "snapshot" as const, - local: initialLocal, - remote: initialRemote, - }), - Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => event.cwd === cwd), - Stream.map((event) => event.event), - ), - ).pipe(Stream.ensuring(release)); - }), - ); + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(cwd); + const cachedStatus = yield* getCachedStatus(cwd); + const initialRemote = cachedStatus?.remote?.value ?? null; + yield* retainRemotePoller( + cwd, + options?.automaticRemoteRefreshInterval ?? + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + cachedStatus?.remote === null || cachedStatus?.remote === undefined, + ); - return VcsStatusBroadcaster.of({ - getStatus, - refreshLocalStatus, - refreshStatus, - streamStatus, - }); - }), -); + const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return VcsStatusBroadcaster.of({ + getStatus, + refreshLocalStatus, + refreshStatus, + streamStatus, + }); +}); + +export const layer = Layer.effect(VcsStatusBroadcaster, make); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts deleted file mode 100644 index 61056042bf3..00000000000 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ /dev/null @@ -1,123 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import fsPromises from "node:fs/promises"; - -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { - WorkspaceFileSystem, - WorkspaceFileSystemError, - type WorkspaceFileSystemShape, -} from "../Services/WorkspaceFileSystem.ts"; -import * as WorkspaceEntries from "../WorkspaceEntries.ts"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; - -const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; - -export const makeWorkspaceFileSystem = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - - const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( - function* (input) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - - const result = yield* Effect.tryPromise({ - try: async () => { - const [realWorkspaceRoot, realTargetPath] = await Promise.all([ - fsPromises.realpath(input.cwd), - fsPromises.realpath(target.absolutePath), - ]); - const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); - if ( - relativeRealPath.startsWith(`..${path.sep}`) || - relativeRealPath === ".." || - path.isAbsolute(relativeRealPath) - ) { - throw new Error("Workspace file path resolves outside the project root."); - } - - const handle = await fsPromises.open(realTargetPath, "r"); - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - throw new Error("Workspace path is not a file."); - } - const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); - const buffer = Buffer.alloc(bytesToRead); - const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); - const fileBytes = buffer.subarray(0, bytesRead); - if (fileBytes.includes(0)) { - throw new Error("Binary files cannot be previewed as text."); - } - const contents = new TextDecoder("utf-8").decode(fileBytes); - return { - relativePath: target.relativePath, - contents, - byteLength: stat.size, - truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, - }; - } finally { - await handle.close(); - } - }, - catch: (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.readFile", - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); - - return result; - }, - ); - - const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( - "WorkspaceFileSystem.writeFile", - )(function* (input) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - - yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.makeDirectory", - detail: cause.message, - cause, - }), - ), - ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.writeFile", - detail: cause.message, - cause, - }), - ), - ); - yield* workspaceEntries.refresh(input.cwd); - return { relativePath: target.relativePath }; - }); - return { readFile, writeFile } satisfies WorkspaceFileSystemShape; -}); - -export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts deleted file mode 100644 index dfe02e8f67c..00000000000 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as NodeOS from "node:os"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { - WorkspacePaths, - WorkspacePathOutsideRootError, - WorkspaceRootCreateFailedError, - WorkspaceRootNotDirectoryError, - WorkspaceRootNotExistsError, - type WorkspacePathsShape, -} from "../Services/WorkspacePaths.ts"; - -function toPosixRelativePath(input: string): string { - return input.replaceAll("\\", "/"); -} - -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - -export const makeWorkspacePaths = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( - "WorkspacePaths.normalizeWorkspaceRoot", - )(function* (workspaceRoot, options) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - let workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); - if (!workspaceStat && options?.createIfMissing) { - yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( - Effect.mapError( - () => - new WorkspaceRootCreateFailedError({ - workspaceRoot, - normalizedWorkspaceRoot, - }), - ), - ); - workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); - } - if (!workspaceStat) { - return yield* new WorkspaceRootNotExistsError({ - workspaceRoot, - normalizedWorkspaceRoot, - }); - } - if (workspaceStat.type !== "Directory") { - return yield* new WorkspaceRootNotDirectoryError({ - workspaceRoot, - normalizedWorkspaceRoot, - }); - } - return normalizedWorkspaceRoot; - }); - - const resolveRelativePathWithinRoot: WorkspacePathsShape["resolveRelativePathWithinRoot"] = - Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { - const normalizedInputPath = input.relativePath.trim(); - if (path.isAbsolute(normalizedInputPath)) { - return yield* new WorkspacePathOutsideRootError({ - workspaceRoot: input.workspaceRoot, - relativePath: input.relativePath, - }); - } - - const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); - if ( - relativeToRoot.length === 0 || - relativeToRoot === "." || - relativeToRoot.startsWith("../") || - relativeToRoot === ".." || - path.isAbsolute(relativeToRoot) - ) { - return yield* new WorkspacePathOutsideRootError({ - workspaceRoot: input.workspaceRoot, - relativePath: input.relativePath, - }); - } - - return { - absolutePath, - relativePath: relativeToRoot, - }; - }); - - return { - normalizeWorkspaceRoot, - resolveRelativePathWithinRoot, - } satisfies WorkspacePathsShape; -}); - -export const WorkspacePathsLive = Layer.effect(WorkspacePaths, makeWorkspacePaths); diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts deleted file mode 100644 index 5126ec417bf..00000000000 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * WorkspaceFileSystem - Effect service contract for workspace file mutations. - * - * Owns workspace-root-relative file write operations and their associated - * safety checks and cache invalidation hooks. - * - * @module WorkspaceFileSystem - */ -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { - ProjectReadFileInput, - ProjectReadFileResult, - ProjectWriteFileInput, - ProjectWriteFileResult, -} from "@t3tools/contracts"; -import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; - -export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( - "WorkspaceFileSystemError", - { - cwd: Schema.String, - relativePath: Schema.optional(Schema.String), - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return this.detail; - } -} - -/** - * WorkspaceFileSystemShape - Service API for workspace-relative file operations. - */ -export interface WorkspaceFileSystemShape { - /** - * Read a UTF-8 text file relative to the workspace root. - */ - readonly readFile: ( - input: ProjectReadFileInput, - ) => Effect.Effect< - ProjectReadFileResult, - WorkspaceFileSystemError | WorkspacePathOutsideRootError - >; - - /** - * Write a file relative to the workspace root. - * - * Creates parent directories as needed and rejects paths that escape the - * workspace root. - */ - readonly writeFile: ( - input: ProjectWriteFileInput, - ) => Effect.Effect< - ProjectWriteFileResult, - WorkspaceFileSystemError | WorkspacePathOutsideRootError - >; -} - -/** - * WorkspaceFileSystem - Service tag for workspace file operations. - */ -export class WorkspaceFileSystem extends Context.Service< - WorkspaceFileSystem, - WorkspaceFileSystemShape ->()("t3/workspace/Services/WorkspaceFileSystem") {} diff --git a/apps/server/src/workspace/Services/WorkspacePaths.ts b/apps/server/src/workspace/Services/WorkspacePaths.ts deleted file mode 100644 index 7c57ca19bd2..00000000000 --- a/apps/server/src/workspace/Services/WorkspacePaths.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * WorkspacePaths - Effect service contract for workspace path handling. - * - * Owns normalization and validation of workspace roots plus safe resolution of - * workspace-root-relative paths. - * - * @module WorkspacePaths - */ -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( - "WorkspaceRootNotExistsError", - { - workspaceRoot: Schema.String, - normalizedWorkspaceRoot: Schema.String, - }, -) { - override get message(): string { - return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`; - } -} - -export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()( - "WorkspaceRootCreateFailedError", - { - workspaceRoot: Schema.String, - normalizedWorkspaceRoot: Schema.String, - }, -) { - override get message(): string { - return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; - } -} - -export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( - "WorkspaceRootNotDirectoryError", - { - workspaceRoot: Schema.String, - normalizedWorkspaceRoot: Schema.String, - }, -) { - override get message(): string { - return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`; - } -} - -export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass()( - "WorkspacePathOutsideRootError", - { - workspaceRoot: Schema.String, - relativePath: Schema.String, - }, -) { - override get message(): string { - return `Workspace file path must be relative to the project root: ${this.relativePath}`; - } -} - -export const WorkspacePathsError = Schema.Union([ - WorkspaceRootNotExistsError, - WorkspaceRootCreateFailedError, - WorkspaceRootNotDirectoryError, - WorkspacePathOutsideRootError, -]); -export type WorkspacePathsError = typeof WorkspacePathsError.Type; - -/** - * WorkspacePathsShape - Service API for workspace path normalization and guards. - */ -export interface WorkspacePathsShape { - /** - * Normalize a user-provided workspace root and verify it exists as a directory. - */ - readonly normalizeWorkspaceRoot: ( - workspaceRoot: string, - options?: { readonly createIfMissing?: boolean }, - ) => Effect.Effect< - string, - WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError - >; - - /** - * Resolve a relative path within a validated workspace root. - * - * Rejects absolute paths and traversal attempts outside the workspace root. - */ - readonly resolveRelativePathWithinRoot: (input: { - workspaceRoot: string; - relativePath: string; - }) => Effect.Effect< - { absolutePath: string; relativePath: string }, - WorkspacePathOutsideRootError - >; -} - -/** - * WorkspacePaths - Service tag for workspace path normalization and resolution. - */ -export class WorkspacePaths extends Context.Service()( - "t3/workspace/Services/WorkspacePaths", -) {} diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index f8a518d8b33..a08350ed959 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,26 +1,32 @@ // @effect-diagnostics nodeBuiltinImport:off -import fsPromises from "node:fs/promises"; +import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { FileFinder } from "@ff-labs/fff-node"; -import { it, afterEach, describe, expect, vi } from "@effect/vitest"; +import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { vi } from "vite-plus/test"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readdir: vi.fn(actual.readdir) }; +}); const TestLayer = Layer.empty.pipe( - Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), Layer.provide( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", }), ), @@ -363,7 +369,10 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }) .pipe(Effect.flip); - expect(error.detail).toBe("Relative filesystem browse paths require a current project."); + expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError"); + expect(error.message).toBe( + "A current project is required to browse relative workspace path './src'.", + ); }), ); @@ -373,7 +382,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); - vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); + vi.mocked(NodeFSP.readdir).mockRejectedValueOnce(denied); const result = yield* workspaceEntries.browse({ partialPath: yield* appendSeparator(cwd), diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bf9a51c74db..7501cbe0eab 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -20,29 +20,66 @@ import type { import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import * as WorkspacePaths from "./Services/WorkspacePaths.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; -export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesError", +export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesWindowsPathUnsupportedError", { - cwd: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + platform: Schema.String, + }, +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Windows-style workspace path '${this.partialPath}' is not supported on '${this.platform}'${cwd}.`; + } +} + +export class WorkspaceEntriesCurrentProjectRequiredError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesCurrentProjectRequiredError", + { + partialPath: Schema.String, }, -) {} +) { + override get message(): string { + return `A current project is required to browse relative workspace path '${this.partialPath}'.`; + } +} -export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesBrowseError", +export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesReadDirectoryError", { cwd: Schema.optional(Schema.String), partialPath: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + parentPath: Schema.String, + cause: Schema.Defect(), }, -) {} +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Failed to read workspace directory '${this.parentPath}' while browsing '${this.partialPath}'${cwd}.`; + } +} + +export const WorkspaceEntriesBrowseError = Schema.Union([ + WorkspaceEntriesWindowsPathUnsupportedError, + WorkspaceEntriesCurrentProjectRequiredError, + WorkspaceEntriesReadDirectoryError, +]); +export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; + +export const WorkspaceEntriesError = Schema.Union([ + WorkspacePaths.WorkspaceRootNotExistsError, + WorkspacePaths.WorkspaceRootCreateFailedError, + WorkspacePaths.WorkspaceRootStatFailedError, + WorkspacePaths.WorkspaceRootNotDirectoryError, + WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, + WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, + WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed, +]); +export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; export class WorkspaceEntries extends Context.Service< WorkspaceEntries, @@ -70,38 +107,32 @@ function expandHomePath(input: string, path: Path.Path): string { return input; } -const resolveBrowseTarget = ( +const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, -): Effect.Effect => - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { - return yield* new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.resolveBrowseTarget", - detail: "Windows-style paths are only supported on Windows.", - }); - } - - if (!isExplicitRelativePath(input.partialPath)) { - return path.resolve(expandHomePath(input.partialPath, path)); - } - - if (!input.cwd) { - return yield* new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.resolveBrowseTarget", - detail: "Relative filesystem browse paths require a current project.", - }); - } - - return path.resolve(expandHomePath(input.cwd, path), input.partialPath); - }); +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesWindowsPathUnsupportedError({ + cwd: input.cwd, + partialPath: input.partialPath, + platform, + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return path.resolve(expandHomePath(input.partialPath, path)); + } -const make = Effect.gen(function* () { + if (!input.cwd) { + return yield* new WorkspaceEntriesCurrentProjectRequiredError({ + partialPath: input.partialPath, + }); + } + return path.resolve(expandHomePath(input.cwd, path), input.partialPath); +}); + +export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; @@ -109,17 +140,7 @@ const make = Effect.gen(function* () { const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, ): Effect.fn.Return { - return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd, - operation: "workspaceEntries.normalizeWorkspaceRoot", - detail: cause.message, - cause, - }), - ), - ); + return yield* workspacePaths.normalizeWorkspaceRoot(cwd); }); const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( @@ -130,20 +151,29 @@ const make = Effect.gen(function* () { if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { return; } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + cause, + }); + yield* workspaceSearchIndexes.invalidate(normalizedCwd); + }); yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; yield* searchIndex.refresh(); }).pipe( Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.catch((cause) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - cause, - }); - yield* workspaceSearchIndexes.invalidate(normalizedCwd); - }), - ), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), ); }, ); @@ -158,11 +188,10 @@ const make = Effect.gen(function* () { const dirents = yield* Effect.tryPromise({ try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), catch: (cause) => - new WorkspaceEntriesBrowseError({ + new WorkspaceEntriesReadDirectoryError({ cwd: input.cwd, partialPath: input.partialPath, - operation: "workspaceEntries.browse.readDirectory", - detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + parentPath, cause, }), }).pipe( @@ -208,18 +237,7 @@ const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.search(normalizedQuery, input.limit); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd: input.cwd, - operation: "workspaceEntries.search", - detail: cause.message, - cause, - }), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); @@ -229,18 +247,7 @@ const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd: input.cwd, - operation: "workspaceEntries.list", - detail: cause.message, - cause, - }), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts similarity index 55% rename from apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts rename to apps/server/src/workspace/WorkspaceFileSystem.test.ts index 5a4ec54686e..cecffbc1993 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -5,26 +5,25 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import { ServerConfig } from "../../config.ts"; -import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import * as WorkspaceEntries from "../WorkspaceEntries.ts"; -import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; -import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; -import { WorkspacePathsLive } from "./WorkspacePaths.ts"; - -const ProjectLayer = WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), +import * as ServerConfig from "../config.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +const ProjectLayer = WorkspaceFileSystem.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), ); const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), - Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", }), ), @@ -56,7 +55,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i describe("readFile", () => { it.effect("reads UTF-8 files relative to the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); @@ -76,7 +75,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i it.effect("rejects reads outside the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; const error = yield* workspaceFileSystem @@ -91,7 +90,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i it.effect("rejects symlinks that resolve outside the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cwd = yield* makeTempDir; @@ -105,8 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const error = yield* workspaceFileSystem .readFile({ cwd, relativePath: "linked-secret.txt" }) .pipe(Effect.flip); + const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); + const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt")); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked-secret.txt", + resolvedWorkspaceRoot, + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("rejects directories without manufacturing an I/O cause", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.makeDirectory(path.join(cwd, "src")); - expect(error.message).toContain("resolves outside the project root"); + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "src" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src")); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "src", + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("rejects binary files without leaking their contents into the error", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const absolutePath = path.join(cwd, "asset.bin"); + yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62])); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "asset.bin" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(absolutePath); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "asset.bin", + resolvedPath, + }); + expect("cause" in error).toBe(false); + expect("contents" in error).toBe(false); + }), + ); + + it.effect("preserves the real cause and path for I/O failures", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const resolvedPath = path.join(cwd, "missing.txt"); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "missing.txt" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "missing.txt", + resolvedPath, + operationPath: resolvedPath, + operation: "realpath-target", + }); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); }), ); }); @@ -114,7 +194,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i describe("writeFile", () => { it.effect("writes files relative to the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -135,7 +215,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); @@ -160,7 +240,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i it.effect("rejects writes outside the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts new file mode 100644 index 00000000000..e2dc9cbbb39 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -0,0 +1,303 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * WorkspaceFileSystem - Effect service contract for workspace file mutations. + * + * Owns workspace-root-relative file read/write operations and their associated + * safety checks and cache invalidation hooks. + * + * @module WorkspaceFileSystem + */ +import * as NodeFSP from "node:fs/promises"; + +import type { + ProjectReadFileInput, + ProjectReadFileResult, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; + +export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( + "WorkspaceFileSystemOperationError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + operationPath: Schema.String, + operation: Schema.Literals([ + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file", + ]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`; + } +} + +export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass()( + "WorkspaceFilePathEscapeError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedWorkspaceRoot: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`; + } +} + +export class WorkspacePathNotFileError extends Schema.TaggedErrorClass()( + "WorkspacePathNotFileError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`; + } +} + +export class WorkspaceBinaryFileError extends Schema.TaggedErrorClass()( + "WorkspaceBinaryFileError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`; + } +} + +export const WorkspaceFileSystemError = Schema.Union([ + WorkspaceFileSystemOperationError, + WorkspaceFilePathEscapeError, + WorkspacePathNotFileError, + WorkspaceBinaryFileError, +]); +export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; + +/** Service tag for workspace file operations. */ +export class WorkspaceFileSystem extends Context.Service< + WorkspaceFileSystem, + { + /** Read a UTF-8 text file relative to the workspace root. */ + readonly readFile: ( + input: ProjectReadFileInput, + ) => Effect.Effect< + ProjectReadFileResult, + WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError + >; + /** + * Write a file relative to the workspace root. + * + * Creates parent directories as needed and rejects paths that escape the + * workspace root. + */ + readonly writeFile: ( + input: ProjectWriteFileInput, + ) => Effect.Effect< + ProjectWriteFileResult, + WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError + >; + } +>()("t3/workspace/WorkspaceFileSystem") {} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( + "WorkspaceFileSystem.readFile", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + const realWorkspaceRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.cwd), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: input.cwd, + operation: "realpath-workspace-root", + cause, + }), + }); + const realTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(target.absolutePath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "realpath-target", + cause, + }), + }); + const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); + if ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ) { + return yield* new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); + } + + return yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => NodeFSP.open(realTargetPath, "r"), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "open", + cause, + }), + }), + (handle) => + Effect.gen(function* () { + const stat = yield* Effect.tryPromise({ + try: () => handle.stat(), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "stat", + cause, + }), + }); + if (!stat.isFile()) { + return yield* new WorkspacePathNotFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); + } + + const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); + const buffer = Buffer.alloc(bytesToRead); + const { bytesRead } = yield* Effect.tryPromise({ + try: () => handle.read(buffer, 0, bytesToRead, 0), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "read", + cause, + }), + }); + const fileBytes = buffer.subarray(0, bytesRead); + if (fileBytes.includes(0)) { + return yield* new WorkspaceBinaryFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); + } + + return { + relativePath: target.relativePath, + contents: new TextDecoder("utf-8").decode(fileBytes), + byteLength: stat.size, + truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, + }; + }), + (handle) => + Effect.tryPromise({ + try: () => handle.close(), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "close", + cause, + }), + }), + ); + }); + + const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( + "WorkspaceFileSystem.writeFile", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: path.dirname(target.absolutePath), + operation: "make-directory", + cause, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "write-file", + cause, + }), + ), + ); + yield* workspaceEntries.refresh(input.cwd); + return { relativePath: target.relativePath }; + }); + + return WorkspaceFileSystem.of({ readFile, writeFile }); +}); + +export const layer = Layer.effect(WorkspaceFileSystem, make); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts similarity index 54% rename from apps/server/src/workspace/Layers/WorkspacePaths.test.ts rename to apps/server/src/workspace/WorkspacePaths.test.ts index 0a9252a7def..4f3bc833b4c 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -4,12 +4,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; -import { WorkspacePathsLive } from "./WorkspacePaths.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(NodeServices.layer), ); @@ -38,7 +38,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { describe("normalizeWorkspaceRoot", () => { it.effect("resolves an existing directory", () => Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const cwd = yield* makeTempDir(); const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd); @@ -49,7 +49,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("rejects missing directories", () => Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const cwd = yield* makeTempDir(); const path = yield* Path.Path; @@ -63,7 +63,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("creates missing directories when createIfMissing is enabled", () => Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* makeTempDir(); const path = yield* Path.Path; @@ -81,7 +81,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("rejects file paths", () => Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const cwd = yield* makeTempDir(); const path = yield* Path.Path; const filePath = path.join(cwd, "README.md"); @@ -92,12 +92,85 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { expect(error.message).toContain("Workspace root is not a directory:"); }), ); + + it.effect("preserves non-NotFound stat failures while validating the root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + stat: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: String(path), + description: "Test PermissionDenied stat failure.", + }), + ), + }), + ); + const path = yield* Path.Path; + const workspaceRoot = " ./permission-denied "; + const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); + + const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); + expect(error).toMatchObject({ + workspaceRoot, + normalizedWorkspaceRoot, + phase: "validate-existing", + }); + }), + ); + + it.effect("preserves stat failures while verifying a newly created root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + let statCalls = 0; + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + stat: (path) => { + statCalls += 1; + const reason = statCalls === 1 ? "NotFound" : "PermissionDenied"; + return Effect.fail( + PlatformError.systemError({ + _tag: reason, + module: "FileSystem", + method: "stat", + pathOrDescriptor: String(path), + description: `Test ${reason} stat failure.`, + }), + ); + }, + makeDirectory: () => Effect.void, + }), + ); + const path = yield* Path.Path; + const workspaceRoot = " ./created-then-unreadable "; + const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); + + const error = yield* workspacePaths + .normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); + expect(error).toMatchObject({ + workspaceRoot, + normalizedWorkspaceRoot, + phase: "verify-created", + }); + }), + ); }); describe("resolveRelativePathWithinRoot", () => { it.effect("resolves relative paths inside the workspace root", () => Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const cwd = yield* makeTempDir(); const path = yield* Path.Path; @@ -115,7 +188,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("rejects paths that escape the workspace root", () => Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const cwd = yield* makeTempDir(); const error = yield* workspacePaths diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts new file mode 100644 index 00000000000..5acf6677cde --- /dev/null +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -0,0 +1,236 @@ +/** + * WorkspacePaths - Effect service contract for workspace path handling. + * + * Owns normalization and validation of workspace roots plus safe resolution of + * workspace-root-relative paths. + * + * @module WorkspacePaths + */ +import * as NodeOS from "node:os"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( + "WorkspaceRootNotExistsError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`; + } +} + +export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()( + "WorkspaceRootCreateFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; + } +} + +export class WorkspaceRootStatFailedError extends Schema.TaggedErrorClass()( + "WorkspaceRootStatFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + phase: Schema.Literals(["validate-existing", "verify-created"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`; + } +} + +export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( + "WorkspaceRootNotDirectoryError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`; + } +} + +export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass()( + "WorkspacePathOutsideRootError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + }, +) { + override get message(): string { + return `Workspace file path must be relative to the project root: ${this.relativePath}`; + } +} + +export const WorkspacePathsError = Schema.Union([ + WorkspaceRootNotExistsError, + WorkspaceRootCreateFailedError, + WorkspaceRootStatFailedError, + WorkspaceRootNotDirectoryError, + WorkspacePathOutsideRootError, +]); +export type WorkspacePathsError = typeof WorkspacePathsError.Type; + +/** Service tag for workspace path normalization and resolution. */ +export class WorkspacePaths extends Context.Service< + WorkspacePaths, + { + /** Normalize a user-provided workspace root and verify it exists as a directory. */ + readonly normalizeWorkspaceRoot: ( + workspaceRoot: string, + options?: { readonly createIfMissing?: boolean }, + ) => Effect.Effect< + string, + | WorkspaceRootNotExistsError + | WorkspaceRootCreateFailedError + | WorkspaceRootStatFailedError + | WorkspaceRootNotDirectoryError + >; + /** + * Resolve a relative path within a validated workspace root. + * + * Rejects absolute paths and traversal attempts outside the workspace root. + */ + readonly resolveRelativePathWithinRoot: (input: { + workspaceRoot: string; + relativePath: string; + }) => Effect.Effect< + { absolutePath: string; relativePath: string }, + WorkspacePathOutsideRootError + >; + } +>()("t3/workspace/WorkspacePaths") {} + +function toPosixRelativePath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return NodeOS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(NodeOS.homedir(), input.slice(2)); + } + return input; +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* ( + workspaceRoot: string, + normalizedWorkspaceRoot: string, + phase: WorkspaceRootStatFailedError["phase"], + ) { + return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new WorkspaceRootStatFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + phase, + cause, + }), + ), + onSuccess: Effect.succeed, + }), + ); + }); + + const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( + "WorkspacePaths.normalizeWorkspaceRoot", + )(function* (workspaceRoot, options) { + const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); + let workspaceStat = yield* statWorkspaceRoot( + workspaceRoot, + normalizedWorkspaceRoot, + "validate-existing", + ); + if (!workspaceStat && options?.createIfMissing) { + yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceRootCreateFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + cause, + }), + ), + ); + workspaceStat = yield* statWorkspaceRoot( + workspaceRoot, + normalizedWorkspaceRoot, + "verify-created", + ); + } + if (!workspaceStat) { + return yield* new WorkspaceRootNotExistsError({ + workspaceRoot, + normalizedWorkspaceRoot, + }); + } + if (workspaceStat.type !== "Directory") { + return yield* new WorkspaceRootNotDirectoryError({ + workspaceRoot, + normalizedWorkspaceRoot, + }); + } + return normalizedWorkspaceRoot; + }); + + const resolveRelativePathWithinRoot: WorkspacePaths["Service"]["resolveRelativePathWithinRoot"] = + Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { + const normalizedInputPath = input.relativePath.trim(); + if (path.isAbsolute(normalizedInputPath)) { + return yield* new WorkspacePathOutsideRootError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + }); + } + + const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); + if ( + relativeToRoot.length === 0 || + relativeToRoot === "." || + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + path.isAbsolute(relativeToRoot) + ) { + return yield* new WorkspacePathOutsideRootError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + }); + } + + return { + absolutePath, + relativePath: relativeToRoot, + }; + }); + + return WorkspacePaths.of({ normalizeWorkspaceRoot, resolveRelativePathWithinRoot }); +}); + +export const layer = Layer.effect(WorkspacePaths, make); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts new file mode 100644 index 00000000000..9b7ed4e2453 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -0,0 +1,159 @@ +import { FileFinder } from "@ff-labs/fff-node"; +import { afterEach, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { vi } from "vite-plus/test"; + +import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it.effect("preserves unexpected FileFinder creation failures", () => + Effect.gen(function* () { + const cause = new Error("native initialization failed"); + vi.spyOn(FileFinder, "create").mockImplementationOnce(() => { + throw cause; + }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexCreateFailed", + cwd: "/workspace/project", + reason: "FileFinder.create threw unexpectedly.", + cause, + }); + }), +); + +it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () => + Effect.gen(function* () { + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ + ok: false, + error: "native index rejected the directory", + }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexCreateFailed", + cwd: "/workspace/project", + reason: "native index rejected the directory", + }); + expect(error.cause).toBeUndefined(); + }), +); + +it.effect("preserves FileFinder destroy failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("native destroy failed"); + const finder = { + destroy: vi.fn(() => { + throw cause; + }), + isScanning: vi.fn(() => false), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const exit = yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")).pipe( + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(WorkspaceSearchIndex.WorkspaceSearchIndexDestroyFailed); + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexDestroyFailed", + cwd: "/workspace/project", + cause, + }); + } + }), +); + +it.effect("preserves search and refresh failures with operation context", () => + Effect.scoped( + Effect.gen(function* () { + const searchCause = new Error("native search failed"); + const refreshCause = new Error("native scan failed"); + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => { + throw searchCause; + }), + scanFiles: vi.fn(() => { + throw refreshCause; + }), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const query = "authorization: Bearer secret-token"; + const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const refreshError = yield* Effect.flip(searchIndex.refresh()); + + expect(searchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 4, + reason: "FileFinder.mixedSearch threw unexpectedly.", + cause: searchCause, + }); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(refreshError).toMatchObject({ + _tag: "WorkspaceSearchIndexRefreshFailed", + cwd: "/workspace/project", + reason: "FileFinder.scanFiles threw unexpectedly.", + cause: refreshCause, + }); + }), + ), +); + +it.effect("keeps returned search diagnostics out of the cause chain", () => + Effect.scoped( + Effect.gen(function* () { + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), + scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const query = "authorization: Bearer secret-token"; + const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const refreshError = yield* Effect.flip(searchIndex.refresh()); + + expect(searchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 4, + reason: "native query rejected", + }); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(searchError.cause).toBeUndefined(); + expect(refreshError).toMatchObject({ + _tag: "WorkspaceSearchIndexRefreshFailed", + cwd: "/workspace/project", + reason: "native refresh rejected", + }); + expect(refreshError.cause).toBeUndefined(); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 4bee3cbc089..db4d46851e7 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -23,10 +23,11 @@ export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexDestroyFailed", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to destroy the workspace search index for '${this.cwd}'.`; } } @@ -153,23 +170,35 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn } const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { - const result = FileFinder.create({ - basePath: cwd, - disableMmapCache: true, - disableContentIndexing: true, - aiMode: false, - enableFsRootScanning: true, - enableHomeDirScanning: true, + const result = yield* Effect.try({ + try: () => + FileFinder.create({ + basePath: cwd, + disableMmapCache: true, + disableContentIndexing: true, + aiMode: false, + enableFsRootScanning: true, + enableHomeDirScanning: true, + }), + catch: (cause) => + new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: "FileFinder.create threw unexpectedly.", + cause, + }), }); if (result.ok) return result.value; - return yield* new WorkspaceSearchIndexCreateFailed({ cwd, reason: result.error }); + return yield* new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: result.error, + }); }); -const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( - cwd: string, - finder: FileFinder, -) { - yield* Effect.sync(() => finder.isScanning()).pipe( +const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => + Effect.try({ + try: () => finder.isScanning(), + catch: onFailure, + }).pipe( Effect.repeat({ while: (scanning) => scanning, schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), @@ -179,67 +208,119 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( orElse: () => new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), }), + Effect.withSpan("WorkspaceSearchIndex.waitForScan"), + ); + +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { + const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => + Effect.try({ + try: () => finder.destroy(), + catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), + }).pipe(Effect.orDie), + ); + yield* waitForScan( + cwd, + finder, + (cause) => + new WorkspaceSearchIndexCreateFailed({ + cwd, + reason: "FileFinder.isScanning threw while creating the index.", + cause, + }), ); -}); -const makeWorkspaceSearchIndex = (cwd: string) => - Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy())).pipe( - Effect.tap((finder) => waitForScan(cwd, finder)), - Effect.map((finder) => { - const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( - query: string, - pageSize: number, - ) { - const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); - if (!result.ok) { - return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); - } - return result.value; + const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( + query: string, + pageSize: number, + ) { + const result = yield* Effect.try({ + try: () => finder.mixedSearch(query, { pageSize }), + catch: (cause) => + new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: query.length, + pageSize, + reason: "FileFinder.mixedSearch threw unexpectedly.", + cause, + }), + }); + if (!result.ok) { + return yield* new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: query.length, + pageSize, + reason: result.error, }); + } + return result.value; + }); - const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( - "WorkspaceSearchIndex.refresh", - )(function* () { - const result = yield* Effect.sync(() => finder.scanFiles()); - if (!result.ok) { - return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); - } - yield* waitForScan(cwd, finder); + const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( + "WorkspaceSearchIndex.refresh", + )(function* () { + const result = yield* Effect.try({ + try: () => finder.scanFiles(), + catch: (cause) => + new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: "FileFinder.scanFiles threw unexpectedly.", + cause, + }), + }); + if (!result.ok) { + return yield* new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: result.error, }); + } + yield* waitForScan( + cwd, + finder, + (cause) => + new WorkspaceSearchIndexRefreshFailed({ + cwd, + reason: "FileFinder.isScanning threw while refreshing the index.", + cause, + }), + ); + }); - const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( - function* () { - const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); - const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); - const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => - left.path.localeCompare(right.path), - ); - const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); - return { - entries, - truncated: mapped.truncated || entries.length < sortedEntries.length, - }; - }, + const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( + function* () { + const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); + const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); + const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => + left.path.localeCompare(right.path), ); + const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); + return { + entries, + truncated: mapped.truncated || entries.length < sortedEntries.length, + }; + }, + ); - const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( - "WorkspaceSearchIndex.search", - )(function* (query, limit) { - const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); - return mapMixedSearchResult(result, limit); - }); + const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( + "WorkspaceSearchIndex.search", + )(function* (query, limit) { + const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); + return mapMixedSearchResult(result, limit); + }); - return WorkspaceSearchIndex.of({ list, refresh, search }); - }), - ); + return WorkspaceSearchIndex.of({ list, refresh, search }); +}); -const workspaceSearchIndexLayer = (cwd: string) => - Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd)); +/** + * A layer factory is required because every index is scoped to a concrete + * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; + * using a default cwd here would mix resources from different workspaces. + */ +export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); export class WorkspaceSearchIndexMap extends LayerMap.Service()( "t3/workspace/WorkspaceSearchIndexMap", { - lookup: workspaceSearchIndexLayer, + lookup: layer, idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL, }, ) {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 34c993de84f..554a942d78a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,9 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + type ProjectEntriesFailure, + type ProjectFileFailure, + type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, ProjectSearchEntriesError, @@ -41,8 +44,10 @@ import { RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, + type FilesystemBrowseFailure, FilesystemBrowseError, - AssetAccessError, + AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -56,45 +61,44 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; -import { ServerConfig } from "./config.ts"; -import { Keybindings } from "./keybindings.ts"; +import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as ServerConfig from "./config.ts"; +import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; -import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; -import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; -import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; -import { TerminalManager } from "./terminal/Services/Manager.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; -import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; -import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; -import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; -import { GitWorkflowService } from "./git/GitWorkflowService.ts"; -import { ReviewService } from "./review/ReviewService.ts"; -import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; -import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as ReviewService from "./review/ReviewService.ts"; +import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; -import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; -import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; +import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; @@ -109,10 +113,143 @@ import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); -const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +function unexpectedCompatibilityError(error: never): never { + throw new Error(`Unhandled compatibility error: ${String(error)}`); +} + +/** Preserve the setup runner's broader pre-refactor message normalization. */ +function legacySetupFailureDescription(cause: unknown): string { + if ( + typeof cause === "object" && + cause !== null && + "message" in cause && + typeof cause.message === "string" + ) { + return cause.message; + } + return String(cause); +} + +function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { + readonly failure: ProjectEntriesFailure; + readonly normalizedCwd?: string; + readonly timeout?: string; + readonly detail?: string; +} { + switch (error._tag) { + case "WorkspaceRootNotExistsError": + return { + failure: "workspace_root_not_found", + normalizedCwd: error.normalizedWorkspaceRoot, + }; + case "WorkspaceRootCreateFailedError": + return { + failure: "workspace_root_create_failed", + normalizedCwd: error.normalizedWorkspaceRoot, + }; + case "WorkspaceRootStatFailedError": + return { + failure: "workspace_root_stat_failed", + normalizedCwd: error.normalizedWorkspaceRoot, + detail: error.phase, + }; + case "WorkspaceRootNotDirectoryError": + return { + failure: "workspace_root_not_directory", + normalizedCwd: error.normalizedWorkspaceRoot, + }; + case "WorkspaceSearchIndexCreateFailed": + return { + failure: "search_index_create_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; + case "WorkspaceSearchIndexScanTimedOut": + return { + failure: "search_index_scan_timed_out", + normalizedCwd: error.cwd, + timeout: error.timeout, + }; + case "WorkspaceSearchIndexSearchFailed": + return { + failure: "search_index_search_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; + default: + return unexpectedCompatibilityError(error); + } +} + +function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): { + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; +} { + switch (error._tag) { + case "WorkspaceEntriesWindowsPathUnsupportedError": + return { failure: "windows_path_unsupported", platform: error.platform }; + case "WorkspaceEntriesCurrentProjectRequiredError": + return { failure: "current_project_required" }; + case "WorkspaceEntriesReadDirectoryError": + return { failure: "read_directory_failed", parentPath: error.parentPath }; + default: + return unexpectedCompatibilityError(error); + } +} + +function projectFileFailureContext( + error: + | WorkspaceFileSystem.WorkspaceFileSystemError + | WorkspacePaths.WorkspacePathOutsideRootError, +): { + readonly failure: ProjectFileFailure; + readonly resolvedPath?: string; + readonly resolvedWorkspaceRoot?: string; + readonly operation?: ProjectFileOperation; + readonly operationPath?: string; +} { + switch (error._tag) { + case "WorkspacePathOutsideRootError": + return { failure: "workspace_path_outside_root" }; + case "WorkspaceFileSystemOperationError": + return { + failure: "operation_failed", + resolvedPath: error.resolvedPath, + operation: error.operation, + operationPath: error.operationPath, + }; + case "WorkspaceFilePathEscapeError": + return { + failure: "resolved_path_outside_root", + resolvedPath: error.resolvedPath, + resolvedWorkspaceRoot: error.resolvedWorkspaceRoot, + }; + case "WorkspacePathNotFileError": + return { failure: "path_not_file", resolvedPath: error.resolvedPath }; + case "WorkspaceBinaryFileError": + return { failure: "binary_file", resolvedPath: error.resolvedPath }; + default: + return unexpectedCompatibilityError(error); + } +} + +function projectSetupScriptCompatibilityDetail( + error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError, +): string { + switch (error._tag) { + case "ProjectSetupScriptOperationError": + return legacySetupFailureDescription(error.cause); + case "ProjectSetupScriptProjectNotFoundError": + return "Project was not found for setup script execution."; + default: + return unexpectedCompatibilityError(error); + } +} + function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, { @@ -248,37 +385,38 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = (currentSession: AuthenticatedSession) => +const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; - const checkpointDiffQuery = yield* CheckpointDiffQuery; - const keybindings = yield* Keybindings; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; - const gitWorkflow = yield* GitWorkflowService; - const review = yield* ReviewService; - const vcsProvisioning = yield* VcsProvisioningService; - const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; - const terminalManager = yield* TerminalManager; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const review = yield* ReviewService.ReviewService; + const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const terminalManager = yield* TerminalManager.TerminalManager; const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; - const providerRegistry = yield* ProviderRegistry; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; - const config = yield* ServerConfig; - const lifecycleEvents = yield* ServerLifecycleEvents; - const serverSettings = yield* ServerSettingsService; - const startup = yield* ServerRuntimeStartup; + const config = yield* ServerConfig.ServerConfig; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const repositoryIdentityResolver = yield* RepositoryIdentityResolver; - const serverEnvironment = yield* ServerEnvironment; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const repositoryIdentityResolver = + yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; + const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), Effect.catch((cause) => @@ -287,7 +425,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), ), ); - const sourceControlRepositories = yield* SourceControlRepositoryService; + const sourceControlRepositories = + yield* SourceControlRepositoryService.SourceControlRepositoryService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -560,12 +699,11 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => : Effect.void; const recordSetupScriptLaunchFailure = (input: { - readonly error: unknown; + readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError; readonly requestedAt: string; readonly worktreePath: string; }) => { - const detail = - input.error instanceof Error ? input.error.message : "Unknown setup failure."; + const detail = projectSetupScriptCompatibilityDetail(input.error); return appendSetupScriptActivity({ threadId: command.threadId, kind: "setup-script.failed", @@ -694,10 +832,24 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => } if (bootstrap?.prepareWorktree) { + let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; + if (bootstrap.prepareWorktree.startFromOrigin) { + yield* gitWorkflow.fetchRemote({ + cwd: bootstrap.prepareWorktree.projectCwd, + remoteName: "origin", + }); + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: bootstrap.prepareWorktree.projectCwd, + refName: bootstrap.prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } const worktree = yield* gitWorkflow.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + refName: worktreeBaseRef, newRefName: bootstrap.prepareWorktree.branch, + baseRefName: bootstrap.prepareWorktree.baseBranch, path: null, }); targetWorktreePath = worktree.worktree.path; @@ -753,7 +905,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; - const settings = redactServerSettingsForClient(yield* serverSettings.getSettings); + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -1055,7 +1209,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.serverGetSettings]: (_input) => observeRpcEffect( WS_METHODS.serverGetSettings, - serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)), + serverSettings.getSettings.pipe( + Effect.map(ServerSettings.redactServerSettingsForClient), + ), { "rpc.aggregate": "server", }, @@ -1063,7 +1219,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.serverUpdateSettings]: ({ patch }) => observeRpcEffect( WS_METHODS.serverUpdateSettings, - serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)), + serverSettings + .updateSettings(patch) + .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), { "rpc.aggregate": "server", }, @@ -1169,7 +1327,10 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${cause.detail}`, + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1183,7 +1344,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new ProjectListEntriesError({ - message: `Failed to list workspace entries: ${cause.detail}`, + ...input, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1194,12 +1356,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsReadFile, workspaceFileSystem.readFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Workspace file path must stay within the project root." - : `Failed to read workspace file: ${cause.detail}`; - return new ProjectReadFileError({ message, cause }); - }), + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1207,15 +1371,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsWriteFile, workspaceFileSystem.writeFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Workspace file path must stay within the project root." - : "Failed to write workspace file"; - return new ProjectWriteFileError({ - message, - cause, - }); - }), + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1230,7 +1394,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: cause.detail, + ...input, + ...filesystemBrowseFailureContext(cause), cause, }), ), @@ -1249,15 +1414,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => .pipe( Effect.mapError( (cause) => - new AssetAccessError({ - message: "Failed to resolve workspace context.", + new AssetWorkspaceContextResolutionError({ + resource: input.resource, cause, }), ), ); if (Option.isNone(thread)) { - return yield* new AssetAccessError({ - message: "Workspace context was not found.", + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, }); } const project = yield* projectionSnapshotQuery @@ -1265,15 +1430,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => .pipe( Effect.mapError( (cause) => - new AssetAccessError({ - message: "Failed to resolve workspace context.", + new AssetWorkspaceContextResolutionError({ + resource: input.resource, cause, }), ), ); if (Option.isNone(project)) { - return yield* new AssetAccessError({ - message: "Workspace context was not found.", + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, }); } return yield* issueAssetUrl({ @@ -1476,7 +1641,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.previewAutomationConnect]: (input) => observeRpcStreamEffect( WS_METHODS.previewAutomationConnect, - previewAutomationBroker.connect(input.clientId), + previewAutomationBroker.connect(input), { "rpc.aggregate": "preview-automation" }, ), [WS_METHODS.previewAutomationRespond]: (input) => @@ -1494,7 +1659,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.previewAutomationClearOwner]: (input) => observeRpcEffect( WS_METHODS.previewAutomationClearOwner, - previewAutomationBroker.clearOwner(input.clientId), + previewAutomationBroker.clearOwner(input), { "rpc.aggregate": "preview-automation" }, ), [WS_METHODS.subscribePreviewEvents]: (_input) => @@ -1546,7 +1711,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => redactServerSettingsForClient(settings)), + Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, @@ -1635,10 +1800,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, @@ -1649,7 +1816,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( - SourceControlDiscoveryLayer.layer.pipe( + SourceControlDiscovery.layer.pipe( Layer.provide( SourceControlProviderRegistry.layer.pipe( Layer.provide( diff --git a/apps/web/package.json b/apps/web/package.json index 06a204cf917..d11b4623e54 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,14 +8,12 @@ "build": "vp build", "preview": "vp preview", "typecheck": "tsgo --noEmit", - "test": "vp test run --passWithNoTests --project unit", - "test:browser": "vp test run --project browser", - "test:browser:install": "playwright install --with-deps chromium" + "test": "vp test run --passWithNoTests --project unit" }, "dependencies": { "@base-ui/react": "^1.4.1", - "@clerk/clerk-js": "^6.16.0", - "@clerk/react": "^6.9.0", + "@clerk/electron": "catalog:", + "@clerk/react": "catalog:", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -24,7 +22,7 @@ "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.0.0-beta.44", + "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", @@ -32,7 +30,6 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", - "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", @@ -64,10 +61,8 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "msw": "2.12.11", - "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "vite": "catalog:", - "vite-plus": "catalog:", - "vitest-browser-react": "^2.0.5" + "vite-plus": "catalog:" } } diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx new file mode 100644 index 00000000000..9112e31cb86 --- /dev/null +++ b/apps/web/src/AppRoot.test.tsx @@ -0,0 +1,22 @@ +import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; +import { RouterProvider } from "@tanstack/react-router"; +import { describe, expect, it } from "vite-plus/test"; + +import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; +import type { AppRouter } from "./router"; +import { AppRoot } from "./AppRoot"; + +describe("AppRoot", () => { + it("shares the application atom registry with routed UI and the Electron browser host", () => { + const root = AppRoot({ router: {} as AppRouter }); + + expect(root.type).toBe(AppAtomRegistryProvider); + const children = Children.toArray( + (root as ReactElement<{ readonly children: ReactNode }>).props.children, + ); + expect(children).toHaveLength(2); + expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); + expect(isValidElement(children[1]) && children[1].type).toBe(ElectronBrowserHost); + }); +}); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx new file mode 100644 index 00000000000..1ecb9f6b7b6 --- /dev/null +++ b/apps/web/src/AppRoot.tsx @@ -0,0 +1,19 @@ +import { RouterProvider } from "@tanstack/react-router"; + +import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; +import type { AppRouter } from "./router"; + +/** + * Owns renderer-wide providers. The Electron browser host intentionally sits + * outside the router so its webviews survive route transitions, but it must + * share the same atom registry as routed UI. + */ +export function AppRoot({ router }: { readonly router: AppRouter }) { + return ( + + + + + ); +} diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts new file mode 100644 index 00000000000..e4634f5b98d --- /dev/null +++ b/apps/web/src/assets/assetUrls.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAssetUrl } from "./assetUrls"; + +describe("resolveAssetUrl", () => { + it("resolves an environment-relative asset URL", () => { + expect( + resolveAssetUrl("https://environment.example/base/", "/api/assets/signed-token/favicon.png"), + ).toBe("https://environment.example/api/assets/signed-token/favicon.png"); + }); + + it("rejects an invalid environment base URL", () => { + expect(resolveAssetUrl("not a URL", "/api/assets/signed-token/favicon.png")).toBeNull(); + }); +}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index e4fba2c5b99..673b093e333 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,89 +1,48 @@ +import { useAtomValue } from "@effect/atom-react"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { useEffect, useMemo, useState } from "react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useMemo } from "react"; -import { readEnvironmentApi } from "~/environmentApi"; -import { readEnvironmentConnection } from "~/environments/runtime"; +import { assetEnvironment } from "~/state/assets"; +import { usePreparedConnection } from "~/state/session"; -const REFRESH_MARGIN_MS = 30_000; +export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -interface CachedAssetUrl { - readonly url: string; - readonly expiresAt: number; -} - -const assetUrlCache = new Map(); -const assetUrlRequests = new Map>(); - -function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string { - return `${environmentId}:${JSON.stringify(resource)}`; -} - -export async function resolveAssetUrl( - environmentId: EnvironmentId, - resource: AssetResource, -): Promise { - const key = assetCacheKey(environmentId, resource); - const cached = assetUrlCache.get(key); - if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) { - return cached; - } - - const inFlight = assetUrlRequests.get(key); - if (inFlight) { - return inFlight; +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const preparedConnection = usePreparedConnection(environmentId); + const result = useAtomValue( + assetEnvironment.createUrl({ + environmentId, + input: { resource }, + }), + ); + if (preparedConnection._tag === "None" || result._tag !== "Success") { + return null; } - - const request = (async () => { - const api = readEnvironmentApi(environmentId); - const connection = readEnvironmentConnection(environmentId); - if (!api || !connection) { - throw new Error("Environment is not connected."); - } - const result = await api.assets.createUrl({ resource }); - const cachedResult = { - url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(), - expiresAt: result.expiresAt, - }; - assetUrlCache.set(key, cachedResult); - return cachedResult; - })().finally(() => { - assetUrlRequests.delete(key); - }); - assetUrlRequests.set(key, request); - return request; + return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); } -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { - const resourceJson = JSON.stringify(resource); - const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]); - const key = assetCacheKey(environmentId, stableResource); - const [url, setUrl] = useState(() => assetUrlCache.get(key)?.url ?? null); - - useEffect(() => { - let cancelled = false; - let refreshTimer: ReturnType | undefined; - - const load = () => { - void resolveAssetUrl(environmentId, stableResource) - .then((result) => { - if (cancelled) return; - setUrl(result.url); - refreshTimer = setTimeout( - load, - Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS), - ); - }) - .catch(() => { - if (!cancelled) setUrl(null); - }); - }; - load(); - - return () => { - cancelled = true; - if (refreshTimer) clearTimeout(refreshTimer); - }; - }, [environmentId, key, stableResource]); - - return url; +export function useAssetUrls( + environmentId: EnvironmentId, + resources: ReadonlyArray, +): ReadonlyArray { + const preparedConnection = usePreparedConnection(environmentId); + const results = useAtomValue( + assetEnvironment.createUrls({ + environmentId, + resources, + }), + ); + return useMemo( + () => + preparedConnection._tag === "None" + ? resources.map(() => null) + : results.map((result) => + AsyncResult.isSuccess(result) + ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) + : null, + ), + [preparedConnection, resources, results], + ); } diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 6815cd70f8c..ced16c15f4e 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -1,5 +1,4 @@ import { - AuthSessionState as AuthSessionStateSchema, EnvironmentAuthInvalidError, type AuthBrowserSessionResult, type AuthCreatePairingCredentialInput, @@ -8,10 +7,11 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; +import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; +import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./lib/runtime"; type TestWindow = { location: URL; @@ -36,8 +36,6 @@ const DESKTOP_AUTH = { } as const; const SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-04-05T00:00:00.000Z"); -const encodeAuthSessionState = Schema.encodeSync(AuthSessionStateSchema); - const unauthenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({ authenticated: false, auth, @@ -73,6 +71,18 @@ function installTestBrowser(url: string) { return testWindow; } +function installDesktopBootstrap() { + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; +} + function sequence(...values: ReadonlyArray) { let index = 0; return () => values[Math.min(index++, values.length - 1)]!; @@ -117,6 +127,7 @@ describe("resolveInitialServerAuthGateState", () => { disposeHttpTest = undefined; const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); __resetServerAuthBootstrapForTests(); + __setPrimaryHttpRunnerForTests(); vi.unstubAllEnvs(); vi.useRealTimers(); vi.restoreAllMocks(); @@ -132,15 +143,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -220,18 +223,22 @@ describe("resolveInitialServerAuthGateState", () => { it("retries transient auth session bootstrap failures after restart", async () => { vi.useFakeTimers(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify(encodeAuthSessionState(unauthenticatedSession(LOOPBACK_AUTH))), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - vi.stubGlobal("fetch", fetchMock); + let attempts = 0; + const request = HttpClientRequest.get("http://localhost/api/auth/session"); + const response = HttpClientResponse.fromWeb( + request, + new Response("Bad Gateway", { status: 502 }), + ); + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + if (attempts < 4) { + throw new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); + } + return unauthenticatedSession(LOOPBACK_AUTH) as A; + }; + __setPrimaryHttpRunnerForTests(runner); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -242,7 +249,7 @@ describe("resolveInitialServerAuthGateState", () => { status: "requires-auth", auth: LOOPBACK_AUTH, }); - expect(fetchMock).toHaveBeenCalledTimes(4); + expect(attempts).toBe(4); }); it("takes a pairing token from the location hash and strips it immediately", async () => { @@ -286,26 +293,74 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("rejects a blank pairing token with a structured validation error", async () => { + const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const error = await submitServerAuthCredential(" ").then( + () => null, + (failure: unknown) => failure, + ); + + expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRequiredError", + providedLength: 3, + message: "Enter a pairing token to continue.", + }); + }); + it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); const testApi = await installAuthApi({ - browserSession: () => - Effect.fail( - new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }), - ), + browserSession: () => Effect.fail(cause), }); - const { submitServerAuthCredential } = await import("./environments/primary"); + const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } = + await import("./environments/primary"); - await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( - "Invalid pairing token. Check the token and try again.", + const error = await submitServerAuthCredential("bad-token").then( + () => null, + (failure: unknown) => failure, ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + providedLength: 9, + message: "Invalid pairing token. Check the token and try again.", + }); + expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true); + if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) { + throw new Error("Expected a structured rejected pairing credential error."); + } + expect(error.cause).toMatchObject({ + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); + it("derives primary request messages from structural request context", async () => { + const cause = new Error("private transport detail"); + const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); + const error = PrimaryEnvironmentRequestError.fromCause({ + operation: "list-pairing-links", + cause, + }); + + expect(error.status).toBe(500); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Primary environment request failed during list-pairing-links (HTTP 500).", + ); + expect(error.message).not.toContain(cause.message); + }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { vi.useFakeTimers(); const nextSession = sequence( @@ -318,15 +373,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -337,6 +384,28 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(3); }); + it("preserves the timeout message when a bootstrapped session never becomes observable", async () => { + vi.useFakeTimers(); + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + + installDesktopBootstrap(); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Timed out waiting for authenticated session after bootstrap.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("memoizes the authenticated gate state after the first successful read", async () => { const testApi = await installAuthApi({ session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts new file mode 100644 index 00000000000..b87276f1b9c --- /dev/null +++ b/apps/web/src/branding.logic.ts @@ -0,0 +1,34 @@ +const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; + +export function formatAppDisplayName(input: { + readonly baseName: string; + readonly stageLabel: string; +}): string { + return `${input.baseName} (${input.stageLabel})`; +} + +export function resolveServerBackedAppStageLabel(input: { + readonly primaryServerVersion: string | null | undefined; + readonly fallbackStageLabel: string; +}): string { + return input.primaryServerVersion && + NIGHTLY_SERVER_VERSION_PATTERN.test(input.primaryServerVersion) + ? "Nightly" + : input.fallbackStageLabel; +} + +export function resolveServerBackedAppDisplayName(input: { + readonly baseName: string; + readonly fallbackDisplayName: string; + readonly fallbackStageLabel: string; + readonly primaryServerVersion: string | null | undefined; +}): string { + const stageLabel = resolveServerBackedAppStageLabel({ + primaryServerVersion: input.primaryServerVersion, + fallbackStageLabel: input.fallbackStageLabel, + }); + + return stageLabel === input.fallbackStageLabel + ? input.fallbackDisplayName + : formatAppDisplayName({ baseName: input.baseName, stageLabel }); +} diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index d9b69bce94a..4aa969c0279 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -1,4 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + resolveServerBackedAppDisplayName, + resolveServerBackedAppStageLabel, +} from "./branding.logic"; const originalWindow = globalThis.window; @@ -55,3 +59,47 @@ describe("branding", () => { expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull(); }); }); + +describe("branding logic", () => { + it("returns Nightly for nightly primary server versions", () => { + expect( + resolveServerBackedAppStageLabel({ + primaryServerVersion: "0.0.28-nightly.20260616.12", + fallbackStageLabel: "Alpha", + }), + ).toBe("Nightly"); + }); + + it("updates the display name for nightly primary server versions", () => { + expect( + resolveServerBackedAppDisplayName({ + baseName: "T3 Code", + fallbackDisplayName: "T3 Code (Alpha)", + fallbackStageLabel: "Alpha", + primaryServerVersion: "0.0.28-nightly.20260616.12", + }), + ).toBe("T3 Code (Nightly)"); + }); + + it("keeps the fallback display name for stable primary server versions", () => { + expect( + resolveServerBackedAppDisplayName({ + baseName: "T3 Code", + fallbackDisplayName: "T3 Code (Alpha)", + fallbackStageLabel: "Alpha", + primaryServerVersion: "0.0.27", + }), + ).toBe("T3 Code (Alpha)"); + }); + + it("keeps the fallback display name for malformed nightly primary server versions", () => { + expect( + resolveServerBackedAppDisplayName({ + baseName: "T3 Code", + fallbackDisplayName: "T3 Code (Alpha)", + fallbackStageLabel: "Alpha", + primaryServerVersion: "0.0.28-nightly.20260616", + }), + ).toBe("T3 Code (Alpha)"); + }); +}); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 5c1309ca06b..7fc57cf0d03 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,4 +1,5 @@ import type { DesktopAppBranding } from "@t3tools/contracts"; +import { formatAppDisplayName } from "./branding.logic"; function readInjectedDesktopAppBranding(): DesktopAppBranding | null { if (typeof window === "undefined") { @@ -21,5 +22,6 @@ export const APP_STAGE_LABEL = HOSTED_APP_CHANNEL_LABEL ?? (import.meta.env.DEV ? "Dev" : "Alpha"); export const APP_DISPLAY_NAME = - injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; + injectedDesktopAppBranding?.displayName ?? + formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index feac8ed0f22..205dce73583 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,11 +1,11 @@ "use client"; -import { parseScopedThreadKey } from "@t3tools/client-runtime"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { useEffect, useMemo } from "react"; import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; -import { usePreviewStateStore } from "~/previewStateStore"; +import { useActivePreviewSessions } from "~/previewStateStore"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; @@ -13,7 +13,7 @@ import { HostedBrowserWebview } from "./HostedBrowserWebview"; export function ElectronBrowserHost() { const { resolvedTheme } = useTheme(); - const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); + const previewByThreadKey = useActivePreviewSessions(); const sessions = useMemo( () => Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 276a9090af2..cdd33fa150d 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -7,9 +7,9 @@ import { useCallback, useEffect, useRef } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; -import { useBrowserRecordingStore } from "./browserRecording"; +import { useActiveBrowserRecordingTabId } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; -import { acquireDesktopTab } from "./desktopTabLifetime"; +import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; interface ElectronWebview extends HTMLElement { @@ -34,13 +34,21 @@ export function HostedBrowserWebview(props: { const { threadRef, tabId, initialUrl } = props; const config = usePreviewWebviewConfig(threadRef.environmentId); const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const tabLeaseRef = useRef(null); const webviewRef = useRef(null); const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); - const recording = useBrowserRecordingStore((state) => state.activeTabId === tabId); + const recording = useActiveBrowserRecordingTabId() === tabId; usePreviewBridge({ threadRef, tabId }); - useEffect(() => acquireDesktopTab(tabId), [tabId]); + useEffect(() => { + const lease = acquireDesktopTab(tabId); + tabLeaseRef.current = lease; + return () => { + if (tabLeaseRef.current === lease) tabLeaseRef.current = null; + lease.release(); + }; + }, [tabId]); const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; @@ -51,19 +59,34 @@ export function HostedBrowserWebview(props: { const webview = webviewRef.current; const bridge = previewBridge; if (!webview || !config || !bridge) return; + let disposed = false; const register = () => { - try { - const webContentsId = webview.getWebContentsId(); - if (Number.isInteger(webContentsId) && webContentsId > 0) { - void bridge.registerWebview(tabId, webContentsId); + const lease = tabLeaseRef.current; + if (!lease) return; + void (async () => { + try { + // The main-process tab and the DOM webview are created by separate + // effects. Wait for the former so registration cannot race and fail + // with PreviewTabNotFoundError on a fast about:blank attachment. + await lease.ready; + if (disposed || webviewRef.current !== webview) return; + const webContentsId = webview.getWebContentsId(); + if (Number.isInteger(webContentsId) && webContentsId > 0) { + await bridge.registerWebview(tabId, webContentsId); + } + } catch { + // did-attach/dom-ready will retry if the guest was not ready yet. } - } catch { - // A later dom-ready will retry registration. - } + })(); }; + webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); register(); - return () => webview.removeEventListener("dom-ready", register); + return () => { + disposed = true; + webview.removeEventListener("did-attach", register); + webview.removeEventListener("dom-ready", register); + }; }, [config, tabId]); if (!config) return null; diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 8a1c6f41327..5bb3364807d 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -2,11 +2,72 @@ import type { DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, } from "@t3tools/contracts"; -import { create } from "zustand"; +import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +export class BrowserRecordingUnavailableError extends Schema.TaggedErrorClass()( + "BrowserRecordingUnavailableError", + { + tabId: Schema.String, + }, +) { + override get message(): string { + return `Browser recording is unavailable for tab ${this.tabId}.`; + } +} + +export class BrowserRecordingConflictError extends Schema.TaggedErrorClass()( + "BrowserRecordingConflictError", + { + requestedTabId: Schema.String, + activeTabId: Schema.String, + }, +) { + override get message(): string { + return `Cannot record tab ${this.requestedTabId} while tab ${this.activeTabId} is already being recorded.`; + } +} + +export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorClass()( + "BrowserRecordingCanvasUnavailableError", + { + tabId: Schema.String, + width: Schema.Number, + height: Schema.Number, + }, +) { + override get message(): string { + return `Browser recording canvas ${this.width}x${this.height} is unavailable for tab ${this.tabId}.`; + } +} + +export class BrowserRecordingOperationError extends Schema.TaggedErrorClass()( + "BrowserRecordingOperationError", + { + operation: Schema.Literals([ + "initialize-media-recorder", + "subscribe-frames", + "start-media-recorder", + "start-screencast", + "stop-screencast", + "stop-media-recorder", + "save-artifact", + "cleanup", + ]), + tabId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Browser recording operation ${this.operation} failed for tab ${this.tabId}.`; + } +} + interface ActiveRecording { readonly tabId: string; readonly canvas: HTMLCanvasElement; @@ -17,21 +78,14 @@ interface ActiveRecording { readonly startedAt: string; } -interface BrowserRecordingState { - activeTabId: string | null; - startedAt: string | null; - lastArtifact: DesktopPreviewRecordingArtifact | null; - setActive: (tabId: string | null, startedAt: string | null) => void; - setArtifact: (artifact: DesktopPreviewRecordingArtifact) => void; -} +const activeBrowserRecordingTabIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("preview:active-browser-recording-tab"), +); -export const useBrowserRecordingStore = create()((set) => ({ - activeTabId: null, - startedAt: null, - lastArtifact: null, - setActive: (activeTabId, startedAt) => set({ activeTabId, startedAt }), - setArtifact: (lastArtifact) => set({ lastArtifact }), -})); +export function useActiveBrowserRecordingTabId(): string | null { + return useAtomValue(activeBrowserRecordingTabIdAtom); +} let active: ActiveRecording | null = null; let unsubscribeFrames: (() => void) | null = null; @@ -56,36 +110,113 @@ const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { image.src = `data:image/jpeg;base64,${frame.data}`; }; -export async function startBrowserRecording(tabId: string): Promise { +const stopMediaRecorder = async (recorder: MediaRecorder): Promise => { + if (recorder.state === "inactive") return; + const stopped = new Promise((resolve) => + recorder.addEventListener("stop", () => resolve(), { once: true }), + ); + recorder.stop(); + await stopped; +}; + +const clearActiveRecording = (recording: ActiveRecording): void => { + if (active !== recording) return; + active = null; + unsubscribeFrames?.(); + unsubscribeFrames = null; + appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); +}; + +export async function startBrowserRecording(tabId: string): Promise { const bridge = previewBridge; - if (!bridge || active) return; + if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); + if (active) { + if (active.tabId === tabId) return active.startedAt; + throw new BrowserRecordingConflictError({ + requestedTabId: tabId, + activeTabId: active.tabId, + }); + } const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, rect?.width ?? 1280); canvas.height = Math.max(1, rect?.height ?? 800); const context = canvas.getContext("2d", { alpha: false }); - if (!context) throw new Error("Browser recording canvas is unavailable."); - const mimeType = preferredMimeType(); - const recorder = new MediaRecorder(canvas.captureStream(12), { - mimeType, - videoBitsPerSecond: 4_000_000, - }); + if (!context) { + throw new BrowserRecordingCanvasUnavailableError({ + tabId, + width: canvas.width, + height: canvas.height, + }); + } + let mimeType: string; + let recorder: MediaRecorder; + try { + mimeType = preferredMimeType(); + recorder = new MediaRecorder(canvas.captureStream(12), { + mimeType, + videoBitsPerSecond: 4_000_000, + }); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "initialize-media-recorder", + tabId, + cause, + }); + } const startedAt = new Date().toISOString(); const chunks: Blob[] = []; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); }); - active = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; - unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); - recorder.start(1_000); + const recording = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; + active = recording; + try { + unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); + } catch (cause) { + clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ + operation: "subscribe-frames", + tabId, + cause, + }); + } + try { + recorder.start(1_000); + } catch (cause) { + clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ + operation: "start-media-recorder", + tabId, + cause, + }); + } try { await bridge.recording.startScreencast(tabId); - useBrowserRecordingStore.getState().setActive(tabId, startedAt); - } catch (error) { - active = null; - recorder.stop(); - throw error; + } catch (cause) { + let cleanupCause: unknown; + try { + await stopMediaRecorder(recorder); + } catch (error) { + cleanupCause = error; + } finally { + clearActiveRecording(recording); + } + throw new BrowserRecordingOperationError({ + operation: "start-screencast", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser recording start and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); } + appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); + return startedAt; } export async function stopBrowserRecording( @@ -94,22 +225,74 @@ export async function stopBrowserRecording( const bridge = previewBridge; const recording = active; if (!bridge || !recording || recording.tabId !== tabId) return null; - await bridge.recording.stopScreencast(tabId); - const stopped = new Promise((resolve) => - recording.recorder.addEventListener("stop", () => resolve(), { once: true }), - ); - recording.recorder.stop(); - await stopped; - const blob = new Blob(recording.chunks, { type: recording.mimeType }); - const artifact = await bridge.recording.save( - tabId, - recording.mimeType, - new Uint8Array(await blob.arrayBuffer()), - ); - active = null; - unsubscribeFrames?.(); - unsubscribeFrames = null; - useBrowserRecordingStore.getState().setActive(null, null); - useBrowserRecordingStore.getState().setArtifact(artifact); - return artifact; + let result: + | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact } + | { readonly _tag: "Failure"; readonly error: unknown }; + try { + try { + await bridge.recording.stopScreencast(tabId); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "stop-screencast", + tabId, + cause, + }); + } + try { + await stopMediaRecorder(recording.recorder); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "stop-media-recorder", + tabId, + cause, + }); + } + try { + const blob = new Blob(recording.chunks, { type: recording.mimeType }); + const artifact = await bridge.recording.save( + tabId, + recording.mimeType, + new Uint8Array(await blob.arrayBuffer()), + ); + result = { _tag: "Success", artifact }; + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "save-artifact", + tabId, + cause, + }); + } + } catch (error) { + result = { _tag: "Failure", error }; + } + + let cleanupError: BrowserRecordingOperationError | undefined; + try { + await stopMediaRecorder(recording.recorder); + } catch (cause) { + cleanupError = new BrowserRecordingOperationError({ + operation: "stop-media-recorder", + tabId, + cause, + }); + } finally { + clearActiveRecording(recording); + } + + if (result._tag === "Failure") { + if (cleanupError) { + throw new BrowserRecordingOperationError({ + operation: "cleanup", + tabId, + cause: new AggregateError( + [result.error, cleanupError], + `Browser recording stop and cleanup failed for tab ${tabId}.`, + { cause: result.error }, + ), + }); + } + throw result.error; + } + if (cleanupError) throw cleanupError; + return result.artifact; } diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index a50275eb8c0..d3c7f6a8dab 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -1,17 +1,15 @@ import { EnvironmentId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const readEnvironmentConnection = vi.fn(); +const readPreparedConnection = vi.fn(); -vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection })); +vi.mock("~/state/session", () => ({ readPreparedConnection })); describe("browser target resolver", () => { - beforeEach(() => readEnvironmentConnection.mockReset()); + beforeEach(() => readPreparedConnection.mockReset()); it("maps environment ports onto a private network host", async () => { - readEnvironmentConnection.mockReturnValue({ - knownEnvironment: { target: { httpBaseUrl: "http://192.168.1.25:3773" } }, - }); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -28,9 +26,7 @@ describe("browser target resolver", () => { }); it("refuses public relay hosts until the authenticated gateway exists", async () => { - readEnvironmentConnection.mockReturnValue({ - knownEnvironment: { target: { httpBaseUrl: "https://relay.example.com" } }, - }); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect(() => resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -41,9 +37,7 @@ describe("browser target resolver", () => { }); it("normalizes schemeless localhost server-picker values", async () => { - readEnvironmentConnection.mockReturnValue({ - knownEnvironment: { target: { httpBaseUrl: "http://localhost:3773" } }, - }); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://localhost:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( "http://localhost:5173/", @@ -53,6 +47,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173/app?x=1#top"), + ).toBe("http://localhost:5173/app?x=1#top"); + }); + it("normalizes public URLs without treating them as environment ports", async () => { const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( @@ -61,9 +63,7 @@ describe("browser target resolver", () => { }); it("supports private IPv6 environment hosts", async () => { - readEnvironmentConnection.mockReturnValue({ - knownEnvironment: { target: { httpBaseUrl: "http://[::1]:3773" } }, - }); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 12276673002..9142cce1e72 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -5,7 +5,7 @@ import type { } from "@t3tools/contracts"; import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; -import { readEnvironmentConnection } from "~/environments/runtime"; +import { readPreparedConnection } from "~/state/session"; const isPrivateNetworkHost = (host: string): boolean => { const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); @@ -24,6 +24,11 @@ const isPrivateNetworkHost = (host: string): boolean => { ); }; +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +}; + export function resolveBrowserNavigationTarget( environmentId: EnvironmentId, target: BrowserNavigationTarget, @@ -36,9 +41,9 @@ export function resolveBrowserNavigationTarget( environmentId, }; } - const connection = readEnvironmentConnection(environmentId); + const connection = readPreparedConnection(environmentId); if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.knownEnvironment.target.httpBaseUrl); + const environmentUrl = new URL(connection.httpBaseUrl); if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", @@ -68,6 +73,12 @@ export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: const normalizedUrl = normalizePreviewUrl(rawUrl); const parsed = new URL(normalizedUrl); if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.httpBaseUrl); + if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { + return normalizedUrl; + } const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { kind: "environment-port", diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts new file mode 100644 index 00000000000..1e3b1632bcc --- /dev/null +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { closeTab, createTab } = vi.hoisted(() => ({ + closeTab: vi.fn(async () => undefined), + createTab: vi.fn<() => Promise>(), +})); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { closeTab, createTab }, +})); + +import { acquireDesktopTab } from "./desktopTabLifetime"; + +describe("desktopTabLifetime", () => { + beforeEach(() => { + closeTab.mockClear(); + createTab.mockClear(); + }); + + it("shares tab creation readiness across concurrent leases", async () => { + let resolveCreation: (() => void) | undefined; + createTab.mockReturnValueOnce( + new Promise((resolve) => { + resolveCreation = resolve; + }), + ); + + const first = acquireDesktopTab("tab_readiness"); + const second = acquireDesktopTab("tab_readiness"); + + expect(createTab).toHaveBeenCalledOnce(); + expect(first.ready).toBe(second.ready); + + let ready = false; + void first.ready.then(() => { + ready = true; + }); + await Promise.resolve(); + expect(ready).toBe(false); + + resolveCreation?.(); + await first.ready; + expect(ready).toBe(true); + }); +}); diff --git a/apps/web/src/browser/desktopTabLifetime.ts b/apps/web/src/browser/desktopTabLifetime.ts index 4254c7e6afc..d621f6dc30c 100644 --- a/apps/web/src/browser/desktopTabLifetime.ts +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -3,28 +3,42 @@ import { previewBridge } from "~/components/preview/previewBridge"; interface DesktopTabLease { references: number; closeTimer: number | null; + ready: Promise; } const leases = new Map(); -export function acquireDesktopTab(tabId: string): () => void { - const current = leases.get(tabId) ?? { references: 0, closeTimer: null }; +export interface AcquiredDesktopTab { + readonly ready: Promise; + readonly release: () => void; +} + +export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { + const current = + leases.get(tabId) ?? + ({ + references: 0, + closeTimer: null, + ready: previewBridge?.createTab(tabId) ?? Promise.resolve(), + } satisfies DesktopTabLease); if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); current.references += 1; current.closeTimer = null; leases.set(tabId, current); - if (current.references === 1) void previewBridge?.createTab(tabId); - return () => { - const lease = leases.get(tabId); - if (!lease) return; - lease.references = Math.max(0, lease.references - 1); - if (lease.references > 0) return; - lease.closeTimer = window.setTimeout(() => { - const latest = leases.get(tabId); - if (!latest || latest.references > 0) return; - leases.delete(tabId); - void previewBridge?.closeTab(tabId); - }, 0); + return { + ready: current.ready, + release: () => { + const lease = leases.get(tabId); + if (!lease) return; + lease.references = Math.max(0, lease.references - 1); + if (lease.references > 0) return; + lease.closeTimer = window.setTimeout(() => { + const latest = leases.get(tabId); + if (!latest || latest.references > 0) return; + leases.delete(tabId); + void previewBridge?.closeTab(tabId); + }, 0); + }, }; } diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 6fcc8ec9954..b89b87c9289 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -1,36 +1,98 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { + AssetCreateUrlResult, + AssetResource, + EnvironmentId, + PreviewOpenInput, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { + type AtomCommandResult, + mapAtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import { AsyncResult } from "effect/unstable/reactivity"; -import { readEnvironmentApi } from "~/environmentApi"; import { resolveAssetUrl } from "~/assets/assetUrls"; -import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore"; +import { + applyPreviewServerSnapshot, + isPreviewSupportedInRuntime, + rememberPreviewUrl, +} from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); -export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - throw new Error("Environment is not connected."); - } +export class BrowserPreviewUnavailableError extends Data.TaggedError( + "BrowserPreviewUnavailableError", +)<{ + readonly message: string; +}> {} + +export type OpenPreviewMutation = (input: { + readonly environmentId: EnvironmentId; + readonly input: PreviewOpenInput; +}) => Promise>; - const snapshot = await api.preview.open({ threadId: threadRef.threadId, url }); - usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); - usePreviewStateStore.getState().rememberUrl(threadRef, url); - useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); +export async function openUrlInPreview(input: { + readonly threadRef: ScopedThreadRef; + readonly url: string; + readonly openPreview: OpenPreviewMutation; +}): Promise> { + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { threadId: input.threadRef.threadId, url: input.url }, + }); + return mapAtomCommandResult(result, (snapshot) => { + applyPreviewServerSnapshot(input.threadRef, snapshot); + rememberPreviewUrl(input.threadRef, input.url); + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + }); } -export async function openFileInPreview( - threadRef: ScopedThreadRef, - filePath: string, -): Promise { +export async function openFileInPreview(input: { + readonly threadRef: ScopedThreadRef; + readonly filePath: string; + readonly httpBaseUrl: string; + readonly createAssetUrl: (input: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Promise>; + readonly openPreview: OpenPreviewMutation; +}): Promise> { if (!isPreviewSupportedInRuntime()) { - throw new Error("The integrated browser is unavailable in this runtime."); + return AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + message: "The integrated browser is unavailable in this runtime.", + }), + ), + ); + } + const assetResult = await input.createAssetUrl({ + environmentId: input.threadRef.environmentId, + input: { + resource: { + _tag: "workspace-file", + threadId: input.threadRef.threadId, + path: input.filePath, + }, + }, + }); + if (assetResult._tag === "Failure") { + return AsyncResult.failure(assetResult.cause); + } + const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl); + if (assetUrl === null) { + return AsyncResult.failure( + Cause.die(new Error("The environment returned an invalid asset URL.")), + ); } - const asset = await resolveAssetUrl(threadRef.environmentId, { - _tag: "workspace-file", - threadId: threadRef.threadId, - path: filePath, + return openUrlInPreview({ + threadRef: input.threadRef, + url: assetUrl, + openPreview: input.openPreview, }); - await openUrlInPreview(threadRef, asset.url); } diff --git a/apps/web/src/browser/previewWebviewConfigState.test.ts b/apps/web/src/browser/previewWebviewConfigState.test.ts new file mode 100644 index 00000000000..35eb665eb7e --- /dev/null +++ b/apps/web/src/browser/previewWebviewConfigState.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { + loadPreviewWebviewConfig, + PreviewWebviewBridgeUnavailableError, + PreviewWebviewConfigLoadError, +} from "./previewWebviewConfigState"; + +const environmentId = EnvironmentId.make("environment-1"); + +describe("loadPreviewWebviewConfig", () => { + it.effect("reports a structurally distinct missing-bridge failure", () => + Effect.gen(function* () { + const error = yield* loadPreviewWebviewConfig(environmentId, null).pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); + expect(error.environmentId).toBe(environmentId); + expect(error.message).toContain(environmentId); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("preserves the bridge rejection as the load failure cause", () => + Effect.gen(function* () { + const cause = new Error("ipc unavailable"); + const error = yield* loadPreviewWebviewConfig(environmentId, { + getPreviewConfig: () => Promise.reject(cause), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewWebviewConfigLoadError); + expect(error.environmentId).toBe(environmentId); + expect(error.cause).toBe(cause); + expect(error.message).not.toContain(cause.message); + }), + ); + + it.effect("forwards the environment id to the bridge", () => + Effect.gen(function* () { + let requestedEnvironmentId: EnvironmentId | null = null; + const config = { + partition: "persist:test-preview", + webPreferences: "sandbox=yes", + preloadUrl: null, + }; + const result = yield* loadPreviewWebviewConfig(environmentId, { + getPreviewConfig: (input) => { + requestedEnvironmentId = input; + return Promise.resolve(config); + }, + }); + + expect(requestedEnvironmentId).toBe(environmentId); + expect(result).toEqual(config); + }), + ); +}); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 99a8388ec5a..6f1cf058e38 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -1,8 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; -import type { DesktopPreviewWebviewConfig, EnvironmentId } from "@t3tools/contracts"; -import * as Data from "effect/Data"; +import type { + DesktopPreviewBridge, + DesktopPreviewWebviewConfig, + EnvironmentId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; @@ -10,27 +14,51 @@ import { previewBridge } from "~/components/preview/previewBridge"; const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000; const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000; -class PreviewWebviewConfigError extends Data.TaggedError("PreviewWebviewConfigError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class PreviewWebviewBridgeUnavailableError extends Schema.TaggedErrorClass()( + "PreviewWebviewBridgeUnavailableError", + { environmentId: Schema.String }, +) { + override get message(): string { + return `Desktop preview configuration is unavailable for environment "${this.environmentId}".`; + } +} + +export class PreviewWebviewConfigLoadError extends Schema.TaggedErrorClass()( + "PreviewWebviewConfigLoadError", + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to load desktop preview configuration for environment "${this.environmentId}".`; + } +} + +export const PreviewWebviewConfigError = Schema.Union([ + PreviewWebviewBridgeUnavailableError, + PreviewWebviewConfigLoadError, +]); +export type PreviewWebviewConfigError = typeof PreviewWebviewConfigError.Type; + +type PreviewConfigBridge = Pick; + +export const loadPreviewWebviewConfig = ( + environmentId: EnvironmentId, + bridge: PreviewConfigBridge | null = previewBridge, +): Effect.Effect => { + if (bridge === null) { + return Effect.fail(new PreviewWebviewBridgeUnavailableError({ environmentId })); + } + + return Effect.tryPromise({ + try: () => bridge.getPreviewConfig(environmentId), + catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }), + }); +}; const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make( - Effect.tryPromise({ - try: () => { - if (!previewBridge) { - throw new Error("Desktop preview bridge is unavailable."); - } - return previewBridge.getPreviewConfig(environmentId); - }, - catch: (cause) => - new PreviewWebviewConfigError({ - message: "Could not load desktop preview configuration.", - cause, - }), - }), - ).pipe( + Atom.make(loadPreviewWebviewConfig(environmentId)).pipe( Atom.swr({ staleTime: PREVIEW_CONFIG_STALE_TIME_MS, revalidateOnMount: true, diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index e2cf84ccc77..8f849a6e7b3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -1,23 +1,6 @@ -import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -const testEnvironmentId = EnvironmentId.make("environment-1"); - -const savedRegistryRecord: PersistedSavedEnvironmentRecord = { - environmentId: testEnvironmentId, - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, -}; - function createLocalStorageStub(): Storage { const store = new Map(); return { @@ -55,32 +38,56 @@ afterEach(() => { }); describe("clientPersistenceStorage", () => { - it("stores browser secrets inline with the saved environment record", async () => { + it("persists client settings in browser storage", async () => { + getTestWindow(); + const { readBrowserClientSettings, writeBrowserClientSettings } = + await import("./clientPersistenceStorage"); + const settings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "24-hour" as const, + }; + + writeBrowserClientSettings(settings); + + expect(readBrowserClientSettings()).toEqual(settings); + }); + + it("reports structured decode failures while preserving the fallback", async () => { const testWindow = getTestWindow(); - const { - SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, - readBrowserSavedEnvironmentRegistry, - readBrowserSavedEnvironmentSecret, - writeBrowserSavedEnvironmentRegistry, - writeBrowserSavedEnvironmentSecret, - } = await import("./clientPersistenceStorage"); + testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(readBrowserClientSettings()).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + "Could not read persisted client settings.", + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + cause: expect.anything(), + }), + ); + }); - writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); - expect(writeBrowserSavedEnvironmentSecret(testEnvironmentId, "bearer-token")).toBe(true); - writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); + it("defaults word wrap on and discards obsolete wrapping preferences", async () => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + chatWordWrap: false, + diffWordWrap: false, + }), + ); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + const settings = readBrowserClientSettings(); - expect(readBrowserSavedEnvironmentRegistry()).toEqual([savedRegistryRecord]); - expect(readBrowserSavedEnvironmentSecret(testEnvironmentId)).toBe("bearer-token"); - expect( - JSON.parse(testWindow.localStorage.getItem(SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY)!), - ).toEqual({ - version: 1, - records: [ - { - ...savedRegistryRecord, - bearerToken: "bearer-token", - }, - ], - }); + expect(settings).toEqual( + expect.objectContaining({ + wordWrap: true, + }), + ); + expect(settings).not.toHaveProperty("chatWordWrap"); + expect(settings).not.toHaveProperty("diffWordWrap"); }); }); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 2838f502881..5c0ba7c6ecc 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -1,66 +1,13 @@ -import { - ClientSettingsSchema, - EnvironmentId, - type ClientSettings, - type EnvironmentId as EnvironmentIdValue, - type PersistedSavedEnvironmentRecord, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; +import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; -export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; - -const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ - environmentId: EnvironmentId, - label: Schema.String, - httpBaseUrl: Schema.String, - wsBaseUrl: Schema.String, - createdAt: Schema.String, - lastConnectedAt: Schema.NullOr(Schema.String), - desktopSsh: Schema.optionalKey( - Schema.Struct({ - alias: Schema.String, - hostname: Schema.String, - username: Schema.NullOr(Schema.String), - port: Schema.NullOr(Schema.Number), - }), - ), - relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), - bearerToken: Schema.optionalKey(Schema.String), -}); -type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; - -const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({ - version: Schema.optionalKey(Schema.Number), - records: Schema.optionalKey(Schema.Array(BrowserSavedEnvironmentRecordSchema)), -}); -type BrowserSavedEnvironmentRegistryDocument = - typeof BrowserSavedEnvironmentRegistryDocumentSchema.Type; function hasWindow(): boolean { return typeof window !== "undefined"; } -function toPersistedSavedEnvironmentRecord( - record: PersistedSavedEnvironmentRecord, -): PersistedSavedEnvironmentRecord { - const nextRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - }; - return { - ...nextRecord, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - }; -} - export function readBrowserClientSettings(): ClientSettings | null { if (!hasWindow()) { return null; @@ -68,7 +15,8 @@ export function readBrowserClientSettings(): ClientSettings | null { try { return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch { + } catch (error) { + console.error("Could not read persisted client settings.", error); return null; } } @@ -80,138 +28,3 @@ export function writeBrowserClientSettings(settings: ClientSettings): void { setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); } - -function readBrowserSavedEnvironmentRegistryDocument(): BrowserSavedEnvironmentRegistryDocument { - if (!hasWindow()) { - return {}; - } - - try { - const parsed = getLocalStorageItem( - SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, - BrowserSavedEnvironmentRegistryDocumentSchema, - ); - return parsed ?? {}; - } catch { - return {}; - } -} - -function writeBrowserSavedEnvironmentRegistryDocument( - document: BrowserSavedEnvironmentRegistryDocument, -): void { - if (!hasWindow()) { - return; - } - - setLocalStorageItem( - SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, - document, - BrowserSavedEnvironmentRegistryDocumentSchema, - ); -} - -function readBrowserSavedEnvironmentRecordsWithSecrets(): ReadonlyArray { - return readBrowserSavedEnvironmentRegistryDocument().records ?? []; -} - -function writeBrowserSavedEnvironmentRecords( - records: ReadonlyArray, -): void { - writeBrowserSavedEnvironmentRegistryDocument({ - version: 1, - records, - }); -} - -export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray { - return readBrowserSavedEnvironmentRecordsWithSecrets().map((record) => - toPersistedSavedEnvironmentRecord(record), - ); -} - -export function writeBrowserSavedEnvironmentRegistry( - records: ReadonlyArray, -): void { - const existing = new Map( - readBrowserSavedEnvironmentRecordsWithSecrets().map( - (record) => [record.environmentId, record] as const, - ), - ); - writeBrowserSavedEnvironmentRecords( - records.map((record) => { - const bearerToken = existing.get(record.environmentId)?.bearerToken; - return bearerToken - ? { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - bearerToken, - } - : toPersistedSavedEnvironmentRecord(record); - }), - ); -} - -export function readBrowserSavedEnvironmentSecret( - environmentId: EnvironmentIdValue, -): string | null { - return ( - readBrowserSavedEnvironmentRecordsWithSecrets().find( - (record) => record.environmentId === environmentId, - )?.bearerToken ?? null - ); -} - -export function writeBrowserSavedEnvironmentSecret( - environmentId: EnvironmentIdValue, - secret: string, -): boolean { - const document = readBrowserSavedEnvironmentRegistryDocument(); - const records = document.records ?? []; - let found = false; - writeBrowserSavedEnvironmentRegistryDocument({ - version: document.version ?? 1, - // The persistence update is copy-on-write so storage subscribers observe a new document. - // oxlint-disable-next-line oxc/no-map-spread - records: records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - found = true; - const nextRecord: BrowserSavedEnvironmentRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - bearerToken: secret, - }; - return { - ...nextRecord, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - }; - }), - }); - return found; -} - -export function removeBrowserSavedEnvironmentSecret(environmentId: EnvironmentIdValue): void { - const document = readBrowserSavedEnvironmentRegistryDocument(); - writeBrowserSavedEnvironmentRegistryDocument({ - version: document.version ?? 1, - records: (document.records ?? []).map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - return toPersistedSavedEnvironmentRecord(record); - }), - }); -} diff --git a/apps/web/src/cloud/desktopAuth.test.ts b/apps/web/src/cloud/desktopAuth.test.ts deleted file mode 100644 index 520130518d5..00000000000 --- a/apps/web/src/cloud/desktopAuth.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveDesktopCloudAuthOAuthOptions } from "./desktopAuth"; - -describe("resolveDesktopCloudAuthOAuthOptions", () => { - it("ignores absent social provider settings", () => { - expect( - resolveDesktopCloudAuthOAuthOptions({ - environment: { - userSettings: { - social: { - github: null, - google: { - strategy: "oauth_google", - enabled: true, - authenticatable: true, - }, - }, - }, - }, - }), - ).toEqual([ - { - strategy: "oauth_google", - label: "Google", - providerId: "google", - iconUrl: null, - }, - ]); - }); - - it("preserves provider display metadata when Clerk exposes the strategy list", () => { - expect( - resolveDesktopCloudAuthOAuthOptions({ - environment: { - userSettings: { - authenticatableSocialStrategies: ["oauth_google"], - social: { - oauth_google: { - strategy: "oauth_google", - enabled: true, - authenticatable: true, - name: "Google", - logo_url: "https://img.clerk.com/static/google.png", - }, - }, - }, - }, - }), - ).toEqual([ - { - strategy: "oauth_google", - label: "Google", - providerId: "google", - iconUrl: "https://img.clerk.com/static/google.png", - }, - ]); - }); -}); diff --git a/apps/web/src/cloud/desktopAuth.ts b/apps/web/src/cloud/desktopAuth.ts deleted file mode 100644 index 0e2a328c30e..00000000000 --- a/apps/web/src/cloud/desktopAuth.ts +++ /dev/null @@ -1,144 +0,0 @@ -export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`; - -export interface DesktopCloudAuthOAuthOption { - readonly strategy: DesktopCloudAuthOAuthStrategy; - readonly label: string; - readonly providerId: string; - readonly iconUrl: string | null; -} - -interface ClerkOAuthProviderSetting { - readonly enabled?: unknown; - readonly authenticatable?: unknown; - readonly strategy?: unknown; - readonly name?: unknown; - readonly logo_url?: unknown; -} - -interface ClerkUserSettingsLike { - readonly authenticatableSocialStrategies?: unknown; - readonly social?: unknown; -} - -interface ClerkEnvironmentLike { - readonly userSettings?: ClerkUserSettingsLike; -} - -interface ClerkLike { - readonly __internal_environment?: ClerkEnvironmentLike; - readonly environment?: ClerkEnvironmentLike; -} - -const isClerkOAuthProviderSetting = (value: unknown): value is ClerkOAuthProviderSetting => - typeof value === "object" && value !== null; - -const OAUTH_LABELS: Readonly> = { - oauth_apple: "Apple", - oauth_discord: "Discord", - oauth_github: "GitHub", - oauth_gitlab: "GitLab", - oauth_google: "Google", - oauth_linear: "Linear", - oauth_microsoft: "Microsoft", - oauth_slack: "Slack", - oauth_x: "X", -}; - -// Mirrors Clerk UI's enabled-provider projection for the local desktop replacement: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/hooks/useEnabledThirdPartyProviders.tsx -export function isDesktopCloudAuthOAuthStrategy( - value: unknown, -): value is DesktopCloudAuthOAuthStrategy { - return typeof value === "string" && value.startsWith("oauth_"); -} - -export function getDesktopCloudAuthOAuthStrategyLabel( - strategy: DesktopCloudAuthOAuthStrategy, -): string { - const mapped = OAUTH_LABELS[strategy]; - if (mapped) return mapped; - return strategy - .replace(/^oauth_custom_/, "") - .replace(/^oauth_/, "") - .split("_") - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -export function resolveDesktopCloudAuthOAuthOptions( - clerk: unknown, -): readonly DesktopCloudAuthOAuthOption[] { - const environment = - (clerk as ClerkLike | null | undefined)?.__internal_environment ?? - (clerk as ClerkLike | null | undefined)?.environment; - const userSettings = environment?.userSettings; - const strategies = userSettings?.authenticatableSocialStrategies; - if (Array.isArray(strategies)) { - return uniqueOptions( - strategies - .filter(isDesktopCloudAuthOAuthStrategy) - .map((strategy) => - createOAuthOption(strategy, findProviderSetting(userSettings, strategy)), - ), - ); - } - - const social = userSettings?.social; - if (!social || typeof social !== "object") { - return []; - } - - return uniqueOptions( - Object.values(social as Record) - .filter(isClerkOAuthProviderSetting) - .filter((provider) => provider.enabled !== false && provider.authenticatable !== false) - .map((provider) => { - const strategy = isDesktopCloudAuthOAuthStrategy(provider.strategy) - ? provider.strategy - : null; - if (!strategy) return null; - return createOAuthOption(strategy, provider); - }) - .filter((option): option is DesktopCloudAuthOAuthOption => option !== null), - ); -} - -function findProviderSetting( - userSettings: ClerkUserSettingsLike | undefined, - strategy: DesktopCloudAuthOAuthStrategy, -): ClerkOAuthProviderSetting | undefined { - const social = userSettings?.social; - if (!social || typeof social !== "object") return undefined; - - return Object.values(social as Record) - .filter(isClerkOAuthProviderSetting) - .find((provider) => provider.strategy === strategy); -} - -function createOAuthOption( - strategy: DesktopCloudAuthOAuthStrategy, - provider?: ClerkOAuthProviderSetting, -): DesktopCloudAuthOAuthOption { - return { - strategy, - label: - typeof provider?.name === "string" && provider.name.trim() - ? provider.name - : getDesktopCloudAuthOAuthStrategyLabel(strategy), - providerId: strategy.replace(/^oauth_/, ""), - iconUrl: - typeof provider?.logo_url === "string" && provider.logo_url.trim() ? provider.logo_url : null, - }; -} - -function uniqueOptions( - options: readonly DesktopCloudAuthOAuthOption[], -): readonly DesktopCloudAuthOAuthOption[] { - const seen = new Set(); - return options.filter((option) => { - if (seen.has(option.strategy)) return false; - seen.add(option.strategy); - return true; - }); -} diff --git a/apps/web/src/cloud/desktopClerk.tsx b/apps/web/src/cloud/desktopClerk.tsx deleted file mode 100644 index 68179f5cf03..00000000000 --- a/apps/web/src/cloud/desktopClerk.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { Clerk } from "@clerk/clerk-js"; -import { - buildClerkUIScriptAttributes, - clerkUIScriptUrl, - InternalClerkProvider, -} from "@clerk/react/internal"; -import type { ClerkProviderProps } from "@clerk/react"; -import { - clerkFrontendApiHostnameFromPublishableKey, - isAllowedClerkFrontendApiHostname, -} from "@t3tools/shared/relayAuth"; -import React, { useEffect, useState } from "react"; - -import { - makeDesktopClerkExternalAccountAdapter, - type DesktopClerkUser, -} from "./desktopClerkExternalAccounts"; - -type DesktopClerkUiCtor = NonNullable; - -interface ClerkFrontendApiRequest { - credentials?: RequestCredentials; - headers?: Headers; - url?: URL; -} - -interface ClerkFrontendApiResponse { - headers: Headers; - payload?: { - errors?: readonly { - code?: string; - }[]; - }; -} - -interface NativeRequestClerk { - readonly publishableKey?: string; - __internal_onBeforeRequest?: ( - listener: (request: ClerkFrontendApiRequest) => void | Promise, - ) => void; - __internal_onAfterResponse?: ( - listener: ( - request: ClerkFrontendApiRequest, - response?: ClerkFrontendApiResponse, - ) => void | Promise, - ) => void; - __unstable__onBeforeRequest?: ( - listener: (request: ClerkFrontendApiRequest) => void | Promise, - ) => void; - __unstable__onAfterResponse?: ( - listener: ( - request: ClerkFrontendApiRequest, - response?: ClerkFrontendApiResponse, - ) => void | Promise, - ) => void; -} - -interface DesktopClerkProviderProps { - readonly children: React.ReactNode; - readonly publishableKey: string; -} - -let desktopClerk: Clerk | null = null; -let desktopClerkFetchInstalled = false; -let desktopClerkUiLoad: Promise | null = null; -let desktopClerkFrontendApiHostname: string | null = null; -let desktopClerkExternalAccountCleanup: (() => void) | null = null; - -const isNativeRequestClerk = (value: unknown): value is NativeRequestClerk => { - if (typeof value !== "object" || value === null) return false; - const candidate = value as { - __internal_onBeforeRequest?: unknown; - __internal_onAfterResponse?: unknown; - __unstable__onBeforeRequest?: unknown; - __unstable__onAfterResponse?: unknown; - }; - return ( - (typeof candidate.__internal_onBeforeRequest === "function" || - typeof candidate.__unstable__onBeforeRequest === "function") && - (typeof candidate.__internal_onAfterResponse === "function" || - typeof candidate.__unstable__onAfterResponse === "function") - ); -}; - -const getStoredClientJwt = (): Promise => - window.desktopBridge?.getCloudAuthToken() ?? Promise.resolve(null); - -const setStoredClientJwt = (token: string): Promise => - window.desktopBridge?.setCloudAuthToken(token) ?? Promise.resolve(false); - -const clearStoredClientJwt = (): Promise => - window.desktopBridge?.clearCloudAuthToken() ?? Promise.resolve(); - -const isClerkFrontendApiUrl = (url: URL): boolean => - url.protocol === "https:" && - isAllowedClerkFrontendApiHostname(url.hostname, desktopClerkFrontendApiHostname); - -const headersToRecord = (headers: Headers): Record => { - const record: Record = {}; - headers.forEach((value, key) => { - record[key] = value; - }); - return record; -}; - -function installDesktopClerkFetchProxy(publishableKey: string): void { - desktopClerkFrontendApiHostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); - if (desktopClerkFetchInstalled) return; - const bridge = window.desktopBridge; - if (!bridge) return; - - const browserFetch = window.fetch.bind(window); - window.fetch = async (input, init) => { - const request = new Request(input, init); - const url = new URL(request.url); - if (!isClerkFrontendApiUrl(url)) { - return browserFetch(input, init); - } - - const body = - request.method === "GET" || request.method === "HEAD" - ? undefined - : await request.clone().text(); - const result = await bridge.fetchCloudAuth({ - url: request.url, - method: request.method, - headers: headersToRecord(request.headers), - ...(body === undefined ? {} : { body }), - }); - - return new Response(result.body, { - status: result.status, - statusText: result.statusText, - headers: result.headers, - }); - }; - desktopClerkFetchInstalled = true; -} - -function installDesktopClerkExternalAccounts(clerk: Clerk): void { - desktopClerkExternalAccountCleanup?.(); - desktopClerkExternalAccountCleanup = null; - - const bridge = window.desktopBridge; - if (!bridge) return; - - const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); - const unsubscribe = clerk.addListener(({ user }) => { - if (user) { - adapter.installUser(user as DesktopClerkUser); - } - }); - desktopClerkExternalAccountCleanup = () => { - unsubscribe(); - adapter.dispose(); - }; -} - -function loadDesktopClerkUi(publishableKey: string): Promise { - if (window.__internal_ClerkUICtor) { - return Promise.resolve(window.__internal_ClerkUICtor); - } - if (desktopClerkUiLoad) { - return desktopClerkUiLoad; - } - - const load = new Promise((resolve, reject) => { - const scriptUrl = clerkUIScriptUrl({ publishableKey }); - const existingScript = document.querySelector( - "script[data-clerk-ui-script]", - ); - - const resolveLoadedUi = () => { - const ClerkUI = window.__internal_ClerkUICtor; - if (ClerkUI) { - resolve(ClerkUI); - return true; - } - return false; - }; - if (resolveLoadedUi()) { - return; - } - - const script = existingScript ?? document.createElement("script"); - script.async = true; - script.crossOrigin = "anonymous"; - script.src = scriptUrl; - script.dataset.clerkUiScript = "true"; - const attributes = buildClerkUIScriptAttributes({ publishableKey }); - for (const [name, value] of Object.entries(attributes)) { - script.setAttribute(name, value); - } - - const timeoutId = window.setTimeout(() => { - reject(new Error("Timed out loading Clerk UI for desktop auth.")); - }, 15_000); - script.addEventListener("load", () => { - window.clearTimeout(timeoutId); - if (!resolveLoadedUi()) { - reject(new Error("Clerk UI loaded without exposing the UI constructor.")); - } - }); - script.addEventListener("error", () => { - window.clearTimeout(timeoutId); - reject(new Error("Failed to load Clerk UI for desktop auth.")); - }); - if (!existingScript) { - document.head.append(script); - } - }).catch((error: unknown) => { - desktopClerkUiLoad = null; - throw error; - }); - - desktopClerkUiLoad = load; - return load; -} - -function getDesktopClerkInstance(publishableKey: string): Clerk { - installDesktopClerkFetchProxy(publishableKey); - - const hasKeyChanged = desktopClerk !== null && desktopClerk.publishableKey !== publishableKey; - if (hasKeyChanged) { - void clearStoredClientJwt(); - desktopClerkExternalAccountCleanup?.(); - desktopClerkExternalAccountCleanup = null; - desktopClerk = null; - } - - if (desktopClerk !== null) { - return desktopClerk; - } - - const nextClerk = new Clerk(publishableKey); - installDesktopClerkExternalAccounts(nextClerk); - if (!isNativeRequestClerk(nextClerk)) { - desktopClerk = nextClerk; - return nextClerk; - } - - const onBeforeRequest = - nextClerk.__internal_onBeforeRequest ?? nextClerk.__unstable__onBeforeRequest; - const onAfterResponse = - nextClerk.__internal_onAfterResponse ?? nextClerk.__unstable__onAfterResponse; - - // Keep this aligned with Clerk Expo's native FAPI adapter: - // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/expo/src/provider/singleton/createClerkInstance.ts - onBeforeRequest(async (request) => { - request.credentials = "omit"; - request.url?.searchParams.append("_is_native", "1"); - const headers = new Headers(request.headers); - - const clientJwt = await getStoredClientJwt(); - headers.set("authorization", clientJwt ?? ""); - headers.set("x-mobile", "1"); - request.headers = headers; - }); - - onAfterResponse(async (_request, response) => { - const clientJwt = response?.headers.get("authorization"); - if (clientJwt) { - await setStoredClientJwt(clientJwt); - } - - const errorCode = response?.payload?.errors?.[0]?.code; - if (errorCode === "native_api_disabled") { - console.error( - "Clerk Native API is disabled. Enable Native applications in the Clerk dashboard for desktop sign-in.", - ); - } - }); - - desktopClerk = nextClerk; - return nextClerk; -} - -export function DesktopClerkProvider({ children, publishableKey }: DesktopClerkProviderProps) { - const [clerkUiCtor, setClerkUiCtor] = useState( - () => window.__internal_ClerkUICtor, - ); - const [clerkUiError, setClerkUiError] = useState(null); - - useEffect(() => { - let isCurrent = true; - void loadDesktopClerkUi(publishableKey).then( - (ClerkUI) => { - if (isCurrent) { - setClerkUiCtor(() => ClerkUI); - } - }, - (error: unknown) => { - if (isCurrent) { - setClerkUiError(error); - } - }, - ); - return () => { - isCurrent = false; - }; - }, [publishableKey]); - - if (!clerkUiCtor) { - if (clerkUiError) { - console.error("Failed to load Clerk UI for desktop auth.", clerkUiError); - } - return null; - } - - const clerk = getDesktopClerkInstance(publishableKey); - return ( - - {children} - - ); -} diff --git a/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts b/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts deleted file mode 100644 index 031094b7a00..00000000000 --- a/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - makeDesktopClerkExternalAccountAdapter, - type DesktopClerkUser, -} from "./desktopClerkExternalAccounts"; - -describe("desktop Clerk external account adapter", () => { - it("replaces renderer redirects with native callbacks and reloads the user on return", async () => { - const callbacks: ((rawUrl: string) => void)[] = []; - const callbackCleanup = vi.fn(); - const bridge = { - createCloudAuthRequest: vi - .fn() - .mockResolvedValueOnce("t3code://auth/callback?t3_state=add") - .mockResolvedValueOnce("t3code://auth/callback?t3_state=reconnect"), - onCloudAuthCallback: vi.fn((listener: (rawUrl: string) => void) => { - callbacks.push(listener); - return callbackCleanup; - }), - }; - const reauthorize = vi.fn(async (_params: Record) => account); - const account = { reauthorize }; - const createExternalAccount = vi.fn(async (_params: Record) => account); - const reload = vi.fn(async () => undefined); - const user = { - externalAccounts: [], - createExternalAccount, - reload, - } satisfies DesktopClerkUser; - const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); - adapter.installUser(user); - - await user.createExternalAccount({ - redirectUrl: "http://127.0.0.1:3773/?__clerk_modal_state=state", - strategy: "oauth_microsoft", - }); - - expect(createExternalAccount).toHaveBeenCalledWith({ - redirectUrl: "t3code://auth/callback?t3_state=add", - strategy: "oauth_microsoft", - }); - - callbacks[0]?.("t3code://auth/callback?t3_state=add"); - await Promise.resolve(); - expect(reload).toHaveBeenCalledOnce(); - - await account.reauthorize({ - redirectUrl: "http://127.0.0.1:3773/?__clerk_modal_state=state", - }); - expect(reauthorize).toHaveBeenCalledWith({ - redirectUrl: "t3code://auth/callback?t3_state=reconnect", - }); - }); - - it("cleans up the pending callback when Clerk rejects account creation", async () => { - const callbackCleanup = vi.fn(); - const bridge = { - createCloudAuthRequest: vi.fn().mockResolvedValue("t3code://auth/callback?t3_state=failed"), - onCloudAuthCallback: vi.fn(() => callbackCleanup), - }; - const createError = new Error("oauth provider unavailable"); - const user = { - externalAccounts: [], - createExternalAccount: vi.fn(async (_params: Record) => { - throw createError; - }), - reload: vi.fn(async () => undefined), - } satisfies DesktopClerkUser; - const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); - adapter.installUser(user); - - await expect(user.createExternalAccount({ strategy: "oauth_microsoft" })).rejects.toBe( - createError, - ); - expect(callbackCleanup).toHaveBeenCalledOnce(); - }); -}); diff --git a/apps/web/src/cloud/desktopClerkExternalAccounts.ts b/apps/web/src/cloud/desktopClerkExternalAccounts.ts deleted file mode 100644 index 01ff8603e25..00000000000 --- a/apps/web/src/cloud/desktopClerkExternalAccounts.ts +++ /dev/null @@ -1,112 +0,0 @@ -interface DesktopClerkExternalAccountParams { - readonly redirectUrl?: string; - readonly [key: string]: unknown; -} - -interface DesktopClerkExternalAccount { - reauthorize: (params: DesktopClerkExternalAccountParams) => Promise; -} - -interface DesktopClerkUser { - readonly externalAccounts: readonly DesktopClerkExternalAccount[]; - createExternalAccount: ( - params: DesktopClerkExternalAccountParams, - ) => Promise; - reload: () => Promise; -} - -interface DesktopClerkExternalAccountBridge { - readonly createCloudAuthRequest: () => Promise; - readonly onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; -} - -interface DesktopClerkExternalAccountAdapter { - readonly dispose: () => void; - readonly installUser: (user: DesktopClerkUser) => void; -} - -interface MakeDesktopClerkExternalAccountAdapterInput { - readonly bridge: DesktopClerkExternalAccountBridge; - readonly reportError?: (message: string, error: unknown) => void; -} - -// Clerk's profile component uses window.location.href as the OAuth callback and navigates the -// current window to the provider. Keep the upstream component intact while adapting its resource -// calls to the native callback bridge: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx -export function makeDesktopClerkExternalAccountAdapter({ - bridge, - reportError = console.error, -}: MakeDesktopClerkExternalAccountAdapterInput): DesktopClerkExternalAccountAdapter { - const installedAccounts = new WeakSet(); - const installedUsers = new WeakSet(); - let callbackGeneration = 0; - let callbackCleanup: (() => void) | null = null; - - const clearCallback = () => { - callbackGeneration += 1; - callbackCleanup?.(); - callbackCleanup = null; - }; - - const createRedirectUrl = async (user: DesktopClerkUser): Promise => { - clearCallback(); - const redirectUrl = await bridge.createCloudAuthRequest(); - const generation = callbackGeneration; - callbackCleanup = bridge.onCloudAuthCallback(() => { - if (generation !== callbackGeneration) return; - clearCallback(); - void user.reload().catch((error: unknown) => { - reportError("Failed to reload Clerk after desktop account linking.", error); - }); - }); - return redirectUrl; - }; - - const installAccount = (user: DesktopClerkUser, account: DesktopClerkExternalAccount): void => { - if (installedAccounts.has(account)) return; - installedAccounts.add(account); - - const reauthorize = account.reauthorize.bind(account); - account.reauthorize = async (params) => { - const redirectUrl = await createRedirectUrl(user); - try { - const nextAccount = await reauthorize({ ...params, redirectUrl }); - installAccount(user, nextAccount); - return nextAccount; - } catch (error) { - clearCallback(); - throw error; - } - }; - }; - - const installUser = (user: DesktopClerkUser): void => { - for (const account of user.externalAccounts) { - installAccount(user, account); - } - if (installedUsers.has(user)) return; - installedUsers.add(user); - - const createExternalAccount = user.createExternalAccount.bind(user); - user.createExternalAccount = async (params) => { - const redirectUrl = await createRedirectUrl(user); - try { - const account = await createExternalAccount({ ...params, redirectUrl }); - installAccount(user, account); - return account; - } catch (error) { - clearCallback(); - throw error; - } - }; - }; - - return { - dispose: clearCallback, - installUser, - }; -} - -export type { DesktopClerkExternalAccountAdapter, DesktopClerkUser }; diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts index 754930d0ced..75951db1baf 100644 --- a/apps/web/src/cloud/dpop.test.ts +++ b/apps/web/src/cloud/dpop.test.ts @@ -1,32 +1,35 @@ import { verifyDpopProof } from "@t3tools/shared/dpop"; +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { decodeJwt } from "jose"; +import { vi } from "vite-plus/test"; import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; describe("browser DPoP proofs", () => { - it("signs relay resource proofs with an access-token hash", async () => { - vi.stubGlobal("indexedDB", undefined); - const issuedAt = Math.floor(Date.now() / 1_000); - const proofKey = await Effect.runPromise(generateBrowserDpopKey); - const proof = await Effect.runPromise( - createBrowserDpopProof({ + it.effect("signs relay resource proofs with an access-token hash", () => + Effect.gen(function* () { + vi.stubGlobal("indexedDB", undefined); + const proofKey = yield* generateBrowserDpopKey; + const proof = yield* createBrowserDpopProof({ method: "POST", url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true", accessToken: "relay-access-token", proofKey, - }).pipe(Effect.provide(browserCryptoLayer)), - ); + }).pipe(Effect.provide(browserCryptoLayer)); + const issuedAt = decodeJwt(proof.proof).iat; + expect(issuedAt).toBeTypeOf("number"); - expect( - verifyDpopProof({ - proof: proof.proof, - method: "POST", - url: "https://relay.example.test/v1/environments/env-1/connect", - expectedThumbprint: proof.thumbprint, - expectedAccessToken: "relay-access-token", - nowEpochSeconds: issuedAt, - }), - ).toMatchObject({ ok: true }); - }); + expect( + verifyDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect", + expectedThumbprint: proof.thumbprint, + expectedAccessToken: "relay-access-token", + nowEpochSeconds: issuedAt!, + }), + ).toMatchObject({ ok: true }); + }), + ); }); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 79b439f6109..d0994955db1 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -107,43 +107,40 @@ export function writeStoredBrowserDpopKey( ); } -export const generateBrowserDpopKey: Effect.Effect = Effect.gen( - function* () { - const generated = yield* Effect.tryPromise({ - try: () => - crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ - "sign", - "verify", - ]) as Promise, - catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), - }); - const privateJwk = yield* Effect.tryPromise({ - try: () => crypto.subtle.exportKey("jwk", generated.privateKey), - catch: (cause) => dpopError("Could not export DPoP private key.", cause), - }); - const publicJwk = yield* Effect.tryPromise({ - try: () => crypto.subtle.exportKey("jwk", generated.publicKey), - catch: (cause) => dpopError("Could not export DPoP public key.", cause), - }).pipe( - Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), - Effect.mapError((cause) => - cause instanceof BrowserDpopError - ? cause - : dpopError("Generated DPoP public key is invalid.", cause), - ), - ); - const privateKey = yield* Effect.tryPromise({ - try: () => - importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, - catch: (cause) => dpopError("Could not import DPoP private key.", cause), - }); - return { - privateKey, - publicJwk, - thumbprint: computeDpopJwkThumbprint(publicJwk), - }; - }, -); +export const generateBrowserDpopKey = Effect.gen(function* () { + const generated = yield* Effect.tryPromise({ + try: () => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]) as Promise, + catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), + }); + const privateJwk = yield* Effect.tryPromise({ + try: () => crypto.subtle.exportKey("jwk", generated.privateKey), + catch: (cause) => dpopError("Could not export DPoP private key.", cause), + }); + const publicJwk = yield* Effect.tryPromise({ + try: () => crypto.subtle.exportKey("jwk", generated.publicKey), + catch: (cause) => dpopError("Could not export DPoP public key.", cause), + }).pipe( + Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), + Effect.mapError((cause) => + cause instanceof BrowserDpopError + ? cause + : dpopError("Generated DPoP public key is invalid.", cause), + ), + ); + const privateKey = yield* Effect.tryPromise({ + try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, + catch: (cause) => dpopError("Could not import DPoP private key.", cause), + }); + return { + privateKey, + publicJwk, + thumbprint: computeDpopJwkThumbprint(publicJwk), + }; +}); export function createBrowserDpopProof(input: { readonly method: string; diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 30cb596781a..7e6f2365e50 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,911 +1,384 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { + type DesktopBridge, + EnvironmentId, + type RelayClientInstallProgressEvent, + WS_METHODS, +} from "@t3tools/contracts"; import { RelayWebClientId } from "@t3tools/contracts/relay"; -import { afterEach, beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { HttpClient } from "effect/unstable/http"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; import { - managedRelayClientLayer, - ManagedRelayClient, - ManagedRelayDpopSigner, - remoteHttpClientLayer, -} from "@t3tools/client-runtime"; + AVAILABLE_CONNECTION_STATE, + EnvironmentSupervisor, + type PreparedConnection, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { type RpcSession } from "@t3tools/client-runtime/rpc"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; +import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; -import type { SavedEnvironmentRecord } from "../environments/runtime"; import { - connectManagedCloudEnvironment, - linkEnvironmentToCloud, + collectCloudLinkTargets, linkPrimaryEnvironmentToCloud, listManagedCloudEnvironments, normalizeRelayBaseUrl, readPrimaryCloudLinkState, + type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; -import { - readPrimaryEnvironmentDescriptor, - readPrimaryEnvironmentTarget, - resolvePrimaryEnvironmentHttpUrl, -} from "../environments/primary"; -const getSavedEnvironmentSecretMock = vi.fn(); -const relayClientInstallDialogHarness = vi.hoisted(() => ({ +const TARGET: CloudLinkTarget = { + environmentId: "environment-1", + label: "Desktop", + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", +}; + +const relayClientInstallDialog = vi.hoisted(() => ({ requestConfirmation: vi.fn(), reportProgress: vi.fn(), finish: vi.fn(), })); -const getRelayClientStatusMock = vi.fn(); -const installRelayClientMock = vi.fn(); -const environmentConnectionMock = { - client: { - cloud: { - getRelayClientStatus: getRelayClientStatusMock, - installRelayClient: installRelayClientMock, - }, - }, -}; -const createProofMock = vi.fn( - (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => - Effect.succeed("web-dpop-proof"), -); -const testDpopSignerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("web-thumbprint"), - createProof: (input) => createProofMock(input), +vi.mock("./relayClientInstallDialog", () => ({ + requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation, + reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress, + finishRelayClientInstall: relayClientInstallDialog.finish, +})); + +const createProof = vi.fn(() => Effect.succeed("dpop-proof")); +const dpopSignerLayer = Layer.succeed( + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("thumbprint"), + createProof, }), ); -function cloudClientLayer() { - const httpClientLayer = remoteHttpClientLayer(globalThis.fetch); +function relayLayer() { + const http = remoteHttpClientLayer(globalThis.fetch); return Layer.mergeAll( - httpClientLayer, - managedRelayClientLayer({ + http, + ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayWebClientId, - }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), + }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)), ); } -const withCloudServices = ( - effect: Effect.Effect, -) => effect.pipe(Effect.provide(cloudClientLayer())); - -vi.mock("../localApi", () => ({ - ensureLocalApi: () => ({ - persistence: { - getSavedEnvironmentSecret: getSavedEnvironmentSecretMock, - }, - }), -})); - -vi.mock("./relayClientInstallDialog", () => ({ - requestRelayClientInstallConfirmation: relayClientInstallDialogHarness.requestConfirmation, - reportRelayClientInstallProgress: relayClientInstallDialogHarness.reportProgress, - finishRelayClientInstall: relayClientInstallDialogHarness.finish, -})); - -vi.mock("../environments/primary", () => ({ - readPrimaryEnvironmentDescriptor: vi.fn(() => null), - readPrimaryEnvironmentTarget: vi.fn(() => null), - resolvePrimaryEnvironmentHttpUrl: vi.fn((path: string) => `http://127.0.0.1:3000${path}`), -})); - -vi.mock("../environments/runtime", () => ({ - getPrimaryEnvironmentConnection: () => environmentConnectionMock, - readEnvironmentConnection: () => environmentConnectionMock, -})); - -const savedEnvironment: SavedEnvironmentRecord = { - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - createdAt: "2026-05-25T00:00:00.000Z", - lastConnectedAt: null, -}; - -function validProof() { - return "signed-environment-link-jwt"; +function registryLayer(options?: { + readonly status?: { readonly status: "available"; readonly version: string }; + readonly installEvents?: ReadonlyArray; +}) { + return Layer.effect( + EnvironmentRegistry, + Effect.gen(function* () { + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Effect.succeed(options?.status ?? { status: "available", version: "2026.6.0" }), + [WS_METHODS.cloudInstallRelayClient]: () => + Stream.fromIterable(options?.installEvents ?? []), + } as unknown as RpcSession["client"]; + const session: RpcSession = { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const target = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make(TARGET.environmentId), + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + wsBaseUrl: TARGET.wsBaseUrl, + }); + const supervisor = EnvironmentSupervisor.of({ + target, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor["Service"]); + const registry = { + run: (_environmentId: EnvironmentId, effect: Effect.Effect) => + Effect.provideService(effect, EnvironmentSupervisor, supervisor), + runStream: (_environmentId: EnvironmentId, stream: Stream.Stream) => + Stream.provideService(stream, EnvironmentSupervisor, supervisor), + } as unknown as EnvironmentRegistry["Service"]; + return EnvironmentRegistry.of(registry); + }), + ); } -function validChallenge() { - return { - challenge: "link-challenge", - expiresAt: "2026-05-25T00:05:00.000Z", - }; +function services(options?: Parameters[0]) { + return Layer.mergeAll(relayLayer(), registryLayer(options)); } -function availableRelayClient() { - return { - status: "available", - executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", - source: "managed", - version: "2026.5.2", - }; +function withServices( + effect: Effect.Effect< + A, + E, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry + >, + options?: Parameters[0], +) { + return effect.pipe(Effect.provide(services(options))); } -function requestBodyText(body: BodyInit | null | undefined): string { +function bodyText(body: BodyInit | null | undefined): string { return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); } -describe("web cloud link environment client", () => { - afterEach(() => { - if ("window" in globalThis) { - Reflect.deleteProperty(window, "desktopBridge"); - } - vi.unstubAllGlobals(); - }); +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); + relayClientInstallDialog.requestConfirmation.mockResolvedValue(true); +}); - beforeEach(() => { - vi.restoreAllMocks(); - vi.clearAllMocks(); - createProofMock.mockClear(); - vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); - getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); - relayClientInstallDialogHarness.requestConfirmation.mockResolvedValue(true); - getRelayClientStatusMock.mockResolvedValue(availableRelayClient()); - installRelayClientMock.mockResolvedValue(availableRelayClient()); - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null); - vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( - (path: string) => `http://127.0.0.1:3000${path}`, - ); - }); +afterEach(() => { + __resetDesktopPrimaryAuthForTests(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); - it("normalizes configured relay base URLs before building relay requests", () => { +describe("web cloud link environment client", () => { + it("normalizes relay URLs and de-duplicates cloud link targets", () => { expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( "https://relay.example.test", ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); + expect(normalizeRelayBaseUrl(" ")).toBeNull(); + expect( + collectCloudLinkTargets({ + primary: TARGET, + saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], + }).map((target) => target.environmentId), + ).toEqual(["environment-1", "environment-2"]); }); - it.effect( - "installs the relay client over environment RPC before requesting a cloud challenge", - () => - Effect.gen(function* () { - getRelayClientStatusMock.mockResolvedValue({ - status: "missing", - version: "2026.5.2", - }); - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json({ malformed: true })); - vi.stubGlobal("fetch", fetchMock); - installRelayClientMock.mockImplementationOnce(async (onProgress) => { - onProgress({ type: "progress", stage: "downloading" }); - return availableRelayClient(); - }); - - yield* withCloudServices( - linkPrimaryEnvironmentToCloud({ - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - - expect(relayClientInstallDialogHarness.requestConfirmation).toHaveBeenCalledWith( - "2026.5.2", - ); - expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); - expect(installRelayClientMock).toHaveBeenCalledOnce(); - expect(relayClientInstallDialogHarness.reportProgress).toHaveBeenCalledWith({ - type: "progress", - stage: "downloading", - }); - expect(relayClientInstallDialogHarness.finish).toHaveBeenCalledOnce(); - expect(installRelayClientMock.mock.invocationCallOrder[0]).toBeLessThan( - fetchMock.mock.invocationCallOrder[0]!, - ); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-link-challenges", - ); - }), - ); - - it.effect("lists relay-managed environments for hosted and served web clients", () => + it.effect("lists relay-managed environments through the typed relay client", () => Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValueOnce( + const fetchMock = vi.fn().mockResolvedValue( Response.json({ environments: [ { - environmentId: "env-1", - label: "Managed desktop", + environmentId: "environment-1", + label: "Desktop", endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", + httpBaseUrl: "https://desktop.example.test", + wsBaseUrl: "wss://desktop.example.test", providerKind: "cloudflare_tunnel", }, - linkedAt: "2026-05-25T00:00:00.000Z", + linkedAt: "2026-06-06T00:00:00.000Z", }, ], }), ); vi.stubGlobal("fetch", fetchMock); - const environments = yield* withCloudServices( + const environments = yield* withServices( listManagedCloudEnvironments({ clerkToken: "clerk-token" }), ); + expect(environments).toHaveLength(1); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/environments", - ); expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); - }), - ); - - it.effect("connects web clients to managed environments with a tunnel-only DPoP token", () => - Effect.gen(function* () { - const environment = { - environmentId: EnvironmentId.make("env-1"), - label: "Managed desktop", - endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", - providerKind: "cloudflare_tunnel" as const, - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "relay-access-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "environment:connect", - }), - ) - .mockResolvedValueOnce( - Response.json({ - environmentId: "env-1", - endpoint: environment.endpoint, - credential: "environment-bootstrap", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ) - .mockResolvedValueOnce( - Response.json({ - environmentId: "env-1", - label: "Managed desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ) - .mockResolvedValueOnce( - Response.json({ - access_token: "environment-access-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 3600, - scope: "orchestration:read orchestration:operate terminal:operate review:write", - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const connection = yield* withCloudServices( - connectManagedCloudEnvironment({ clerkToken: "clerk-token", environment }), - ); - expect(connection).toMatchObject({ - environmentId: "env-1", - accessToken: "environment-access-token", - }); - - const tokenBody = requestBodyText(fetchMock.mock.calls[0]?.[1]?.body); - expect(new URLSearchParams(tokenBody).get("client_id")).toBe("t3-web"); - expect(new URLSearchParams(tokenBody).get("scope")).toBe("environment:connect"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("DPoP relay-access-token"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.dpop).toBe("web-dpop-proof"); - expect(createProofMock).toHaveBeenCalledWith({ - method: "POST", - url: "https://managed.example.test/oauth/token", - }); - const traceparents = fetchMock.mock.calls.map( - (call) => call[1]?.headers.traceparent as string | undefined, - ); - expect(traceparents.every((traceparent) => typeof traceparent === "string")).toBe(true); - expect(new Set(traceparents.map((traceparent) => traceparent?.split("-")[1])).size).toBe(1); - expect(connection.relayTraceHeaders.traceparent?.split("-")[1]).toBe( - traceparents[0]?.split("-")[1], - ); }), ); - it.effect("rejects a stored managed connection for another relay origin", () => + it.effect("reads primary cloud link state from the explicit target", () => Effect.gen(function* () { - const environment = { - environmentId: EnvironmentId.make("env-1"), - label: "Managed desktop", - endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", - providerKind: "cloudflare_tunnel" as const, - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }; - - const error = yield* withCloudServices( - connectManagedCloudEnvironment({ - clerkToken: "clerk-token", - environment, - relayUrl: "https://old-relay.example.test", + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: false, }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - message: "The saved environment is linked through a different configured relay.", - }); - }), - ); - - it.effect("rejects malformed local environment link proofs", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce( - Response.json({ - payload: { - environmentId: "env-1", - }, - signature: "signature-1", - }), - ), ); + vi.stubGlobal("fetch", fetchMock); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Could not obtain environment link proof.", - }); - }), - ); - - it.effect("preserves typed local environment failures while obtaining a link proof", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce( - Response.json( - { - _tag: "EnvironmentHttpUnauthorizedError", - message: "Invalid environment bearer session.", - }, - { status: 401 }, - ), - ), - ); + const state = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", + expect(Option.fromNullishOr(state)).toEqual( + Option.some({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: false, }), - ).pipe(Effect.flip); - expect(error._tag).toBe("CloudEnvironmentLinkError"); - expect(error.message).toBe( - "Could not obtain environment link proof: Invalid environment bearer session.", ); - }), - ); - - it.effect("rejects malformed relay environment link responses", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ), + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/link-state", ); - - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "https://relay.example.test/v1/client/environment-links failed", - }); }), ); - it.effect( - "links the primary local environment through the relay using the owner cookie session", - () => - Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( - (path: string) => `http://127.0.0.1:3000${path}`, - ); - - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: { - providerKind: "cloudflare_tunnel", - connectorToken: "connector-token", - tunnelId: "tunnel-id", - tunnelName: "tunnel-name", - }, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ) - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), - ); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - linkPrimaryEnvironmentToCloud({ - clerkToken: "clerk-token", - }), - ); - - expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-link-challenges", - ); - expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); - - expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-proof", - ); - expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - headers: expect.objectContaining({ - "content-type": "application/json", - }), - }); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ - challenge: "link-challenge", - endpoint: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - providerKind: "cloudflare_tunnel", - }, - origin: { - localHttpHost: "127.0.0.1", - localHttpPort: 3000, - }, - }); - - expect(String(fetchMock.mock.calls[2]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-links", - ); - expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); - expect(fetchMock.mock.calls[2]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[2]?.[1]?.credentials).not.toBe("include"); - expect(fetchMock.mock.calls[2]?.[1]?.headers["content-type"]).toBe("application/json"); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[2]?.[1]?.body))).toMatchObject({ - proof: validProof(), - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }); - - expect(String(fetchMock.mock.calls[3]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/relay-config", - ); - expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - headers: expect.objectContaining({ - "content-type": "application/json", - }), - }); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - endpointRuntime: { - providerKind: "cloudflare_tunnel", - connectorToken: "connector-token", - tunnelId: "tunnel-id", - tunnelName: "tunnel-name", - }, - }); - }), - ); - - it.effect("reads the primary local cloud link state with the owner cookie session", () => + it.effect("uses desktop bearer auth for primary cloud link state", () => Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi.fn().mockResolvedValueOnce( + const fetchMock = vi.fn().mockResolvedValue( Response.json({ linked: true, - cloudUserId: "user_123", + cloudUserId: "user-1", relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", + relayIssuer: "https://relay.example.test", publishAgentActivity: false, }), ); vi.stubGlobal("fetch", fetchMock); - - const state = yield* withCloudServices(readPrimaryCloudLinkState()); - expect(state).toEqual({ - linked: true, - cloudUserId: "user_123", - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - publishAgentActivity: false, - }); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-state", - ); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: "GET", - credentials: "include", + vi.stubGlobal("window", { + location: { origin: "t3code://app" }, + desktopBridge: { + getLocalEnvironmentBearerToken: vi.fn().mockResolvedValue("desktop-bearer-token"), + } as unknown as DesktopBridge, }); - }), - ); - it.effect("clears local relay credentials before revoking the primary cloud link", () => - Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ) - .mockResolvedValueOnce(Response.json({ ok: true })); - vi.stubGlobal("fetch", fetchMock); + yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); - yield* withCloudServices( - unlinkPrimaryEnvironmentFromCloud({ - clerkToken: "clerk-token", - }), - ); - - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - }); - expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-links/env-1", - ); - expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); + const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); + expect(request.credentials).not.toBe("include"); + expect(request.headers.get("authorization")).toBe("Bearer desktop-bearer-token"); }), ); - it.effect("still clears local relay credentials when relay revocation fails", () => + it.effect("updates agent activity publishing for the explicit primary target", () => Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ) - .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - unlinkPrimaryEnvironmentFromCloud({ - clerkToken: "clerk-token", + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: true, }), ); + vi.stubGlobal("fetch", fetchMock); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - }); - }), - ); - - it.effect("rejects primary environment linking when the local environment is not ready", () => - Effect.gen(function* () { - vi.stubGlobal("fetch", vi.fn()); - - const error = yield* withCloudServices( - linkPrimaryEnvironmentToCloud({ - clerkToken: "clerk-token", + const state = yield* withServices( + updatePrimaryCloudPreferences({ + target: TARGET, + publishAgentActivity: true, }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Local environment is not ready yet.", - }); - expect(fetch).not.toHaveBeenCalled(); - }), - ); - - it.effect("preserves relay transport failures while linking environments", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })), ); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "https://relay.example.test/v1/client/environment-links failed", - }); - }), - ); - - it.effect("preserves typed relay error bodies while linking environments", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json( - { - _tag: "RelayEnvironmentLinkProofInvalidError", - code: "environment_link_proof_invalid", - reason: "origin_not_allowed", - traceId: "trace-test", - }, - { status: 400 }, - ), - ), + expect(state.publishAgentActivity).toBe(true); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/preferences", ); - - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + publishAgentActivity: true, }); }), ); - it.effect("rejects relay credentials for a different environment", () => + it.effect("links an available primary environment without invoking installation", () => Effect.gen(function* () { const fetchMock = vi .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce( + Response.json({ + challenge: "challenge", + expiresAt: "2026-06-06T00:05:00.000Z", + }), + ) + .mockResolvedValueOnce(Response.json("signed-proof")) .mockResolvedValueOnce( Response.json({ ok: true, - environmentId: "env-2", + environmentId: TARGET.environmentId, endpoint: { httpBaseUrl: "https://desktop.example.test", wsBaseUrl: "wss://desktop.example.test", providerKind: "cloudflare_tunnel", }, endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", + relayIssuer: "https://relay.example.test", + cloudUserId: "user-1", + environmentCredential: "environment-credential", + cloudMintPublicKey: "public-key", }), + ) + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), ); vi.stubGlobal("fetch", fetchMock); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + linkPrimaryEnvironmentToCloud({ + target: TARGET, clerkToken: "clerk-token", }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", + ); + + expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/link-proof", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + challenge: "challenge", + endpoint: { + httpBaseUrl: TARGET.httpBaseUrl, + wsBaseUrl: TARGET.wsBaseUrl, + }, }); - expect(fetchMock).toHaveBeenCalledTimes(3); }), ); - it.effect("rejects relay credentials for a different managed endpoint provider", () => + it.effect("installs a missing relay client before linking", () => Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "manual", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ); - vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + linkPrimaryEnvironmentToCloud({ + target: TARGET, clerkToken: "clerk-token", }), + { + status: { status: "available", version: "2026.6.0" }, + installEvents: [], + }, ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint provider.", - }); - expect(fetchMock).toHaveBeenCalledTimes(3); + + expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); }), ); - it.effect("passes the relay issuer from the link response into local relay config", () => + it.effect("unlinks locally before revoking the relay record", () => Effect.gen(function* () { const fetchMock = vi .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ) .mockResolvedValueOnce( Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ); + ) + .mockResolvedValueOnce(Response.json({ ok: true })); vi.stubGlobal("fetch", fetchMock); - yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + unlinkPrimaryEnvironmentFromCloud({ + target: TARGET, clerkToken: "clerk-token", }), ); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - }); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain( + `/v1/client/environment-links/${TARGET.environmentId}`, + ); }), ); }); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 4c94ab41660..20bf75c7d6d 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -1,7 +1,9 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, type EnvironmentCloudLinkStateResult, @@ -11,38 +13,25 @@ import { EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, EnvironmentId, + WS_METHODS, } from "@t3tools/contracts"; import { - RelayEnvironmentConnectScope, type RelayClientDeviceRecord, - type RelayEnvironmentLinkResponse, - RelayProtectedError, type RelayClientEnvironmentRecord, + type RelayEnvironmentLinkResponse, type RelayProtectedError as RelayProtectedErrorType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; -import { - exchangeRemoteDpopAccessToken, - fetchRemoteEnvironmentDescriptor, - makeEnvironmentHttpApiClient, - ManagedRelayClient, - ManagedRelayDpopSigner, - type WsRpcClient, -} from "@t3tools/client-runtime"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { request, runStream } from "@t3tools/client-runtime/rpc"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import { ensureLocalApi } from "../localApi"; -import { - getPrimaryEnvironmentConnection, - readEnvironmentConnection, - type SavedEnvironmentRecord, -} from "../environments/runtime"; import { readPrimaryEnvironmentDescriptor, readPrimaryEnvironmentTarget, - resolvePrimaryEnvironmentHttpUrl, } from "../environments/primary"; -import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { resolveCloudPublicConfig } from "./publicConfig"; import { finishRelayClientInstall, @@ -65,6 +54,7 @@ function relayUrl(): string | null { export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ readonly message: string; readonly cause?: unknown; + readonly traceId?: string; }> {} const relayClientRpcError = (message: string) => (cause: unknown) => @@ -74,13 +64,13 @@ const relayClientRpcError = (message: string) => (cause: unknown) => }); function ensureRelayClientAvailable( - client: WsRpcClient, -): Effect.Effect { + environmentId: EnvironmentId, +): Effect.Effect { return Effect.gen(function* () { - const status = yield* Effect.tryPromise({ - try: () => client.cloud.getRelayClientStatus(), - catch: relayClientRpcError("Could not check relay client availability."), - }); + const registry = yield* EnvironmentRegistry; + const status = yield* registry + .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) + .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability."))); if (status.status === "available") return; if (status.status === "unsupported") { return yield* new CloudEnvironmentLinkError({ @@ -98,22 +88,35 @@ function ensureRelayClientAvailable( }); } - const installed = yield* Effect.tryPromise({ - try: () => client.cloud.installRelayClient(reportRelayClientInstallProgress), - catch: relayClientRpcError("Could not install the relay client."), - }).pipe(Effect.ensuring(Effect.sync(finishRelayClientInstall))); - if (installed.status !== "available") { + const installed = yield* registry + .runStream( + environmentId, + runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( + Stream.tap((event) => Effect.sync(() => reportRelayClientInstallProgress(event))), + ), + ) + .pipe( + Stream.runLast, + Effect.mapError(relayClientRpcError("Could not install the relay client.")), + Effect.ensuring(Effect.sync(finishRelayClientInstall)), + ); + if (Option.isNone(installed) || installed.value.type !== "complete") { + return yield* new CloudEnvironmentLinkError({ + message: "The relay client install completed without a final status.", + }); + } + const installedStatus = installed.value.status; + if (installedStatus.status !== "available") { return yield* new CloudEnvironmentLinkError({ message: - installed.status === "unsupported" - ? `T3 Code cannot install the relay client automatically on ${installed.platform}-${installed.arch}.` + installedStatus.status === "unsupported" + ? `T3 Code cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.` : "The relay client is still unavailable after installation.", }); } }); } -const isRelayProtectedError = Schema.is(RelayProtectedError); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ EnvironmentHttpBadRequestError, @@ -156,31 +159,24 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayAgentActivityPublishProofInvalidError": return `Relay rejected the agent activity publish proof (${error.reason}).`; case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; + return `Relay encountered an internal error (${error.reason}).`; } } function decodedRelayClientError(message: string) { - return (cause: unknown) => { - const relayError = findRelayProtectedError(cause); + return (cause: ManagedRelay.ManagedRelayClientError) => { + const relayError = + cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; + const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, + ...(traceId ? { traceId } : {}), }); }; } -function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { - if (isRelayProtectedError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findRelayProtectedError(cause.cause) : null; -} - function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { if (isEnvironmentCloudApiError(cause)) { return cause; @@ -239,16 +235,6 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export interface CloudManagedConnection { - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly label: string; - readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly relayUrl: string; - readonly accessToken: string; - readonly relayTraceHeaders: Headers.Headers; -} - export function collectCloudLinkTargets(input: { readonly primary: CloudLinkTarget | null; readonly saved: ReadonlyArray; @@ -284,7 +270,7 @@ export function listManagedCloudEnvironments(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); @@ -293,7 +279,7 @@ export function listManagedCloudEnvironments(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient .listEnvironments({ clerkToken: input.clerkToken, @@ -315,7 +301,7 @@ export function listCloudDevices(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { if (!relayUrl()) { @@ -323,7 +309,7 @@ export function listCloudDevices(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe( Effect.mapError( (cause) => @@ -336,181 +322,55 @@ export function listCloudDevices(input: { }); } -export function connectManagedCloudEnvironment(input: { - readonly clerkToken: string; - readonly environment: RelayClientEnvironmentRecord; - readonly relayUrl?: string; -}): Effect.Effect< - CloudManagedConnection, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner -> { +export function readPrimaryCloudLinkState(input: { + readonly target: CloudLinkTarget; +}): Effect.Effect { return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); - if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "The saved environment is linked through a different configured relay.", - }); - } - const relayClient = yield* ManagedRelayClient; - const connected = yield* relayClient - .connectEnvironment({ - clerkToken: input.clerkToken, - scopes: [RelayEnvironmentConnectScope], - environmentId: input.environment.environmentId, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not connect to relay-managed environment.", - cause, - }), - ), - ); - if (connected.environmentId !== input.environment.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", - }); - } - if ( - connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl || - connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl || - connected.endpoint.providerKind !== input.environment.endpoint.providerKind - ) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint.", - }); - } - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: connected.endpoint.httpBaseUrl, - }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not read connected environment descriptor.", - cause, - }), - ), - ); - if (descriptor.environmentId !== connected.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Connected endpoint does not match the selected environment.", - }); - } - const signer = yield* ManagedRelayDpopSigner; - const bootstrapProof = yield* signer - .createProof({ - method: "POST", - url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(), - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not create environment DPoP proof.", - cause, - }), - ), - ); - const session = yield* exchangeRemoteDpopAccessToken({ - httpBaseUrl: connected.endpoint.httpBaseUrl, - credential: connected.credential, - dpopProof: bootstrapProof, - }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not authorize managed environment.", - cause, - }), - ), - ); - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: connected.endpoint.httpBaseUrl, - wsBaseUrl: connected.endpoint.wsBaseUrl, - relayUrl: configuredRelayUrl, - accessToken: session.access_token, - relayTraceHeaders: HttpTraceContext.toHeaders(yield* Effect.currentSpan.pipe(Effect.orDie)), - }; - }).pipe( - Effect.withSpan("relay.environment.connect", { - root: true, - attributes: { "relay.environment_id": input.environment.environmentId }, - }), - withRelayClientTracing, - ); -} - -export function readPrimaryCloudLinkState(): Effect.Effect< - CloudLinkState | null, - CloudEnvironmentLinkError, - HttpClient.HttpClient -> { - return Effect.gen(function* () { - if (!readPrimaryCloudLinkTarget()) { - return null; - } - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .linkState({ headers: {} }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not read environment cloud link state.")), - ); - }); + .pipe(Effect.mapError(environmentApiError("Could not read environment cloud link state."))); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } export function updatePrimaryCloudPreferences(input: { + readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean; }): Effect.Effect { return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .preferences({ headers: {}, payload: input, }) .pipe( - withPrimaryEnvironmentRequestInit, Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), ); - }); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } export function unlinkPrimaryEnvironmentFromCloud(input: { + readonly target: CloudLinkTarget; readonly clerkToken: string | null; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient +> { return Effect.gen(function* () { - const target = readPrimaryCloudLinkTarget(); - if (!target) { - return yield* new CloudEnvironmentLinkError({ - message: "Local environment is not ready yet.", - }); - } - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); yield* client.connect .unlink({ headers: {} }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")), - ); + .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud."))); const configuredRelayUrl = relayUrl(); if (configuredRelayUrl && input.clerkToken) { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient .unlinkEnvironment({ clerkToken: input.clerkToken, - environmentId: EnvironmentId.make(target.environmentId), + environmentId: EnvironmentId.make(input.target.environmentId), }) .pipe( Effect.catch((cause) => @@ -520,118 +380,17 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { ), ); } - }); -} - -export function linkEnvironmentToCloud(input: { - readonly environment: SavedEnvironmentRecord; - readonly clerkToken: string; -}): Effect.Effect { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelayClient; - const bearerToken = yield* Effect.tryPromise({ - try: () => - ensureLocalApi().persistence.getSavedEnvironmentSecret(input.environment.environmentId), - catch: (cause) => - new CloudEnvironmentLinkError({ - message: `Could not read saved bearer token for ${input.environment.label}.`, - cause, - }), - }); - if (!bearerToken) { - return yield* new CloudEnvironmentLinkError({ - message: `No saved bearer token for ${input.environment.label}.`, - }); - } - - const connection = readEnvironmentConnection(input.environment.environmentId); - if (!connection) { - return yield* new CloudEnvironmentLinkError({ - message: `${input.environment.label} is not connected.`, - }); - } - yield* ensureRelayClientAvailable(connection.client); - - const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl); - const headers = { authorization: `Bearer ${bearerToken}` }; - - const challenge = yield* relayClient - .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError( - `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, - ), - ), - ); - const proof = yield* environmentClient.connect - .linkProof({ - headers, - payload: { - challenge: challenge.challenge, - relayIssuer: configuredRelayUrl, - endpoint: { - httpBaseUrl: input.environment.httpBaseUrl, - wsBaseUrl: input.environment.wsBaseUrl, - providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, - }, - origin: endpointOrigin(input.environment.httpBaseUrl), - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); - const link = yield* relayClient - .linkEnvironment({ - clerkToken: input.clerkToken, - payload: { - proof, - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), - ), - ); - yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: input.environment.environmentId, - expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, - link, - }); - - yield* environmentClient.connect - .relayConfig({ - headers, - payload: { - relayUrl: configuredRelayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); - }); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } export function linkPrimaryEnvironmentToCloud(input: { + readonly target: CloudLinkTarget; readonly clerkToken: string; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient +> { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { @@ -639,15 +398,9 @@ export function linkPrimaryEnvironmentToCloud(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; - const target = readPrimaryCloudLinkTarget(); - if (!target) { - return yield* new CloudEnvironmentLinkError({ - message: "Local environment is not ready yet.", - }); - } - const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); - yield* ensureRelayClientAvailable(getPrimaryEnvironmentConnection().client); + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); const challenge = yield* relayClient .createEnvironmentLinkChallenge({ @@ -672,17 +425,14 @@ export function linkPrimaryEnvironmentToCloud(input: { challenge: challenge.challenge, relayIssuer: configuredRelayUrl, endpoint: { - httpBaseUrl: target.httpBaseUrl, - wsBaseUrl: target.wsBaseUrl, + httpBaseUrl: input.target.httpBaseUrl, + wsBaseUrl: input.target.wsBaseUrl, providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, }, - origin: endpointOrigin(target.httpBaseUrl), + origin: endpointOrigin(input.target.httpBaseUrl), }, }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not obtain environment link proof.")), - ); + .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); const link = yield* relayClient .linkEnvironment({ clerkToken: input.clerkToken, @@ -699,7 +449,7 @@ export function linkPrimaryEnvironmentToCloud(input: { ), ); yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: target.environmentId, + expectedEnvironmentId: input.target.environmentId, expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, link, }); @@ -716,9 +466,6 @@ export function linkPrimaryEnvironmentToCloud(input: { endpointRuntime: link.endpointRuntime, }, }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not configure environment relay access.")), - ); - }); + .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts new file mode 100644 index 00000000000..4cb62271a48 --- /dev/null +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -0,0 +1,42 @@ +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { + linkPrimaryEnvironmentToCloud, + type CloudLinkTarget, + unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, +} from "./linkEnvironment"; + +const cloudLinkScheduler = createAtomCommandScheduler(); +const cloudLinkConcurrency = { + mode: "serial" as const, + key: (input: { readonly target: CloudLinkTarget }) => input.target.environmentId, +}; + +export const linkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:link-primary-environment", + scheduler: cloudLinkScheduler, + concurrency: cloudLinkConcurrency, + execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string }) => + linkPrimaryEnvironmentToCloud(input), +}); + +export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:unlink-primary-environment", + scheduler: cloudLinkScheduler, + concurrency: cloudLinkConcurrency, + execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => + unlinkPrimaryEnvironmentFromCloud(input), +}); + +export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:update-primary-environment-preferences", + scheduler: cloudLinkScheduler, + concurrency: cloudLinkConcurrency, + execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) => + updatePrimaryCloudPreferences(input), +}); diff --git a/apps/web/src/cloud/managedAuth.test.ts b/apps/web/src/cloud/managedAuth.test.ts new file mode 100644 index 00000000000..aa29a59677e --- /dev/null +++ b/apps/web/src/cloud/managedAuth.test.ts @@ -0,0 +1,55 @@ +import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { + activateManagedRelayAuthentication, + deactivateManagedRelayAuthentication, + readManagedRelayClerkToken, +} from "./managedAuth"; + +vi.mock("@clerk/react", () => ({ + useAuth: vi.fn(), +})); + +vi.mock("../lib/runtime", () => ({ + runtime: { + runPromiseExit: vi.fn(), + }, +})); + +vi.mock("../connection/catalog", () => ({ + environmentCatalog: { + removeRelayEnvironments: {}, + }, +})); + +afterEach(() => { + deactivateManagedRelayAuthentication(); +}); + +describe("managed relay authentication", () => { + it("clears all token access synchronously before account cleanup can fail", async () => { + activateManagedRelayAuthentication("account-1", async () => "account-1-token"); + expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1"); + expect(await readManagedRelayClerkToken()).toBe("account-1-token"); + + deactivateManagedRelayAuthentication(); + const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined); + + expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull(); + expect(await readManagedRelayClerkToken()).toBeNull(); + await cleanup; + }); + + it("replaces an existing account session atomically", () => { + setManagedRelaySession(appAtomRegistry, { + accountId: "account-1", + readClerkToken: async () => "account-1-token", + }); + + activateManagedRelayAuthentication("account-2", async () => "account-2-token"); + + expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-2"); + }); +}); diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx index b00c445f08d..2f631214501 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -1,8 +1,17 @@ import { useAuth } from "@clerk/react"; -import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; -import { useEffect, type ReactNode } from "react"; +import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { + reportAtomCommandResult, + settleAsyncResult, + settlePromise, +} from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; +import { useEffect, useRef, type ReactNode } from "react"; +import { environmentCatalog } from "../connection/catalog"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useAtomCommand } from "../state/use-atom-command"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; let relayTokenProvider: (() => Promise) | null = null; @@ -11,25 +20,97 @@ export async function readManagedRelayClerkToken(): Promise { return relayTokenProvider?.() ?? null; } +export function deactivateManagedRelayAuthentication(): void { + relayTokenProvider = null; + setManagedRelaySession(appAtomRegistry, null); +} + +export function activateManagedRelayAuthentication( + accountId: string, + readClerkToken: () => Promise, +): void { + relayTokenProvider = readClerkToken; + setManagedRelaySession(appAtomRegistry, { + accountId, + readClerkToken, + }); +} + export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { - const { getToken, isSignedIn, userId } = useAuth(); + const { getToken, isLoaded, isSignedIn, userId } = useAuth({ + treatPendingAsSignedOut: false, + }); + const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { + reportFailure: false, + reportDefect: false, + }); + const observedAccountRef = useRef(undefined); + const accountTransitionRef = useRef | null>(null); useEffect(() => { - relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null; - setManagedRelaySession( - appAtomRegistry, - isSignedIn && userId - ? createManagedRelaySession({ - accountId: userId, - readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), - }) - : null, - ); + if (!isLoaded) { + return; + } + + let cancelled = false; + const previousAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + + const queueAccountCleanup = () => { + const previousTransition = accountTransitionRef.current ?? Promise.resolve(); + accountTransitionRef.current = previousTransition.then(async () => { + const results = await Promise.all([ + removeRelayEnvironments(), + settleAsyncResult(() => + runtime.runPromiseExit( + ManagedRelay.ManagedRelayClient.pipe( + Effect.flatMap((client) => client.resetTokenCache), + ), + ), + ), + ]); + for (const result of results) { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + } + }); + return accountTransitionRef.current; + }; + + if (!isSignedIn || !userId) { + deactivateManagedRelayAuthentication(); + if (previousAccount !== null) { + void queueAccountCleanup(); + } + } else { + const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); + const activateSession = () => { + if (!cancelled) { + activateManagedRelayAuthentication(userId, tokenProvider); + } + }; + const activateAfterTransition = (transition: Promise) => { + void (async () => { + const result = await settlePromise(async () => { + await transition; + activateSession(); + }); + reportAtomCommandResult(result, { label: "cloud account activation" }); + })(); + }; + if (previousAccount !== undefined && previousAccount !== null && previousAccount !== userId) { + deactivateManagedRelayAuthentication(); + activateAfterTransition(queueAccountCleanup()); + } else { + activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); + } + } return () => { - relayTokenProvider = null; - setManagedRelaySession(appAtomRegistry, null); + cancelled = true; }; - }, [getToken, isSignedIn, userId]); + }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]); + + useEffect(() => () => deactivateManagedRelayAuthentication(), []); return children; } diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index f34ad2f9c99..52f9b6496c9 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,8 +1,4 @@ -import { - managedRelayClientLayer, - ManagedRelayDpopSigner, - ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayWebClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -17,8 +13,8 @@ import { type BrowserDpopKey, } from "./dpop"; -export const webRelayDpopSignerLayer = Layer.effect( - ManagedRelayDpopSigner, +export const relayDpopSignerLayer = Layer.effect( + ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const keyLoadSemaphore = yield* Semaphore.make(1); @@ -39,24 +35,48 @@ export const webRelayDpopSignerLayer = Layer.effect( return generated; }), ); - const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause }); - return ManagedRelayDpopSigner.of({ + + return ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: loadOrCreateBrowserDpopKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError(signerError), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + keyStore: "indexed-db", + cause: error, + }), + ), + Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"), ), - createProof: (input) => - loadOrCreateBrowserDpopKey.pipe( - Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })), + createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(function* (input) { + const proofKey = yield* loadOrCreateBrowserDpopKey.pipe( + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.map((proof) => proof.proof), - Effect.mapError(signerError), - ), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + }), }); }), ); -export const webManagedRelayClientLayer = (relayUrl: string) => - managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( - Layer.provideMerge(webRelayDpopSignerLayer), +export const managedRelayClientLayer = (relayUrl: string) => + ManagedRelay.layer({ relayUrl, clientId: RelayWebClientId }).pipe( + Layer.provideMerge(relayDpopSignerLayer), ); diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index a31ee9e16f3..5f29c121dbc 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -1,10 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, - ManagedRelayClient, + ManagedRelay, managedRelaySessionAtom, readManagedRelaySnapshotState, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import type { RelayClientDeviceRecord, RelayClientEnvironmentRecord, @@ -13,16 +13,16 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; -import { webRuntime } from "../lib/runtime"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; const managedRelayAtomRuntime = Atom.runtime( Layer.effect( - ManagedRelayClient, - webRuntime.contextEffect.pipe( - Effect.map((context) => Context.get(context, ManagedRelayClient)), + ManagedRelay.ManagedRelayClient, + runtime.contextEffect.pipe( + Effect.map((context) => Context.get(context, ManagedRelay.ManagedRelayClient)), ), ), ); @@ -44,6 +44,15 @@ export function useManagedRelayEnvironments() { ? managedRelayQueryManager.environmentsAtom(accountId) : EMPTY_ENVIRONMENTS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); @@ -51,7 +60,7 @@ export function useManagedRelayEnvironments() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; @@ -62,6 +71,15 @@ export function useManagedRelayDevices() { const accountId = session?.accountId ?? null; const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay device listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId); @@ -69,7 +87,7 @@ export function useManagedRelayDevices() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 095ca842281..34fdacd214a 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -7,51 +7,68 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { HttpClient } from "effect/unstable/http"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; -import { usePrimaryEnvironmentId } from "../environments/primary"; -import { webRuntime } from "../lib/runtime"; +import { usePrimaryEnvironment } from "../state/environments"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { readPrimaryCloudLinkState } from "./linkEnvironment"; +import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; const primaryCloudLinkAtomRuntime = Atom.runtime( Layer.effect( HttpClient.HttpClient, - webRuntime.contextEffect.pipe( + runtime.contextEffect.pipe( Effect.map((context) => Context.get(context, HttpClient.HttpClient)), ), ), ); -const primaryCloudLinkStateAtom = Atom.family((environmentId: EnvironmentId) => - primaryCloudLinkAtomRuntime - .atom(readPrimaryCloudLinkState()) +const primaryCloudLinkStateAtom = Atom.family((key: string) => { + const target = JSON.parse(key) as CloudLinkTarget; + return primaryCloudLinkAtomRuntime + .atom(readPrimaryCloudLinkState({ target })) .pipe( Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), - Atom.withLabel(`primary-cloud-link:${environmentId}`), - ), -); + Atom.withLabel(`primary-cloud-link:${target.environmentId}`), + ); +}); const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make( AsyncResult.success(null), ).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null")); -export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void { - if (environmentId) { - appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId)); +function targetKey(target: CloudLinkTarget): string { + return JSON.stringify(target); +} + +export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { + if (target) { + appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } } export function usePrimaryCloudLinkState() { - const environmentId = usePrimaryEnvironmentId(); - const atom = environmentId - ? primaryCloudLinkStateAtom(environmentId) + const primary = usePrimaryEnvironment(); + const target = useMemo( + () => + primary?.entry.target._tag === "PrimaryConnectionTarget" + ? { + environmentId: primary.environmentId, + label: primary.label, + httpBaseUrl: primary.entry.target.httpBaseUrl, + wsBaseUrl: primary.entry.target.wsBaseUrl, + } + : null, + [primary], + ); + const atom = target + ? primaryCloudLinkStateAtom(targetKey(target)) : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; const result = useAtomValue(atom); const refresh = useCallback(() => { - refreshPrimaryCloudLinkState(environmentId); - }, [environmentId]); + refreshPrimaryCloudLinkState(target); + }, [target]); let error: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); @@ -63,5 +80,6 @@ export function usePrimaryCloudLinkState() { error, isPending: result.waiting, refresh, + target, }; } diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index bb188d0b110..d42aa34baa2 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { hasCloudPublicConfig } from "./publicConfig.ts"; +import { + CloudPublicConfigMissingError, + hasCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "./publicConfig.ts"; afterEach(() => { vi.unstubAllEnvs(); @@ -30,4 +34,12 @@ describe("hasCloudPublicConfig", () => { expect(hasCloudPublicConfig()).toBe(false); }); + + it("reports the missing Clerk JWT template as structured configuration", () => { + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); + + expect(() => resolveRelayClerkTokenOptions()).toThrowError( + new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), + ); + }); }); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index f7b3ca6bc31..d9d0e5f44cb 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -1,5 +1,17 @@ import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Schema from "effect/Schema"; + +export class CloudPublicConfigMissingError extends Schema.TaggedErrorClass()( + "CloudPublicConfigMissingError", + { + key: Schema.Literal("T3CODE_CLERK_JWT_TEMPLATE"), + }, +) { + override get message(): string { + return `${this.key} is not configured.`; + } +} export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; @@ -65,7 +77,7 @@ export function hasCloudPublicConfig(): boolean { export function resolveRelayClerkTokenOptions() { const { clerkJwtTemplate } = resolveCloudPublicConfig(); if (!clerkJwtTemplate) { - throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); + throw new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }); } return relayClerkTokenOptions(clerkJwtTemplate); } diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts index 8f2a25bc3a0..7bd8d4967e4 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.test.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -4,6 +4,7 @@ import { completeRelayClientInstallDialogClose, finishRelayClientInstall, readRelayClientInstallDialogState, + RelayClientInstallConfirmationConflictError, reportRelayClientInstallProgress, requestRelayClientInstallConfirmation, resetRelayClientInstallDialogForTests, @@ -67,4 +68,28 @@ describe("relay client install dialog coordinator", () => { completeRelayClientInstallDialogClose(); expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); }); + + it("rejects concurrent confirmation with the active install state", async () => { + const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + respondToRelayClientInstallConfirmation(true); + await expect(confirmation).resolves.toBe(true); + reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); + + const error = await requestRelayClientInstallConfirmation("2026.6.0").then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(RelayClientInstallConfirmationConflictError); + expect(error).toMatchObject({ + requestedVersion: "2026.6.0", + activeVersion: "2026.5.2", + activeDialogStatus: "installing", + activeInstallStage: "downloading", + }); + expect(error).not.toHaveProperty("cause"); + expect((error as Error).message).toBe( + "Cannot confirm relay client installation 2026.6.0; installation 2026.5.2 has dialog status installing.", + ); + }); }); diff --git a/apps/web/src/cloud/relayClientInstallDialog.ts b/apps/web/src/cloud/relayClientInstallDialog.ts index 908890ad1f5..b1b0c6607e3 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.ts @@ -1,7 +1,23 @@ -import type { - RelayClientInstallProgressEvent, - RelayClientInstallProgressStage, +import { + RelayClientInstallProgressStageSchema, + type RelayClientInstallProgressEvent, + type RelayClientInstallProgressStage, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class RelayClientInstallConfirmationConflictError extends Schema.TaggedErrorClass()( + "RelayClientInstallConfirmationConflictError", + { + requestedVersion: Schema.String, + activeVersion: Schema.String, + activeDialogStatus: Schema.Literals(["confirming", "installing", "closing"]), + activeInstallStage: Schema.optional(RelayClientInstallProgressStageSchema), + }, +) { + override get message(): string { + return `Cannot confirm relay client installation ${this.requestedVersion}; installation ${this.activeVersion} has dialog status ${this.activeDialogStatus}.`; + } +} export type RelayClientInstallDialogState = | { readonly status: "idle" } @@ -47,7 +63,17 @@ export function subscribeRelayClientInstallDialog(listener: () => void): () => v export function requestRelayClientInstallConfirmation(version: string): Promise { if (state.status !== "idle") { - return Promise.reject(new Error("A relay client installation is already in progress.")); + const activeInstall = state.status === "closing" ? state.view : state; + return Promise.reject( + new RelayClientInstallConfirmationConflictError({ + requestedVersion: version, + activeVersion: activeInstall.version, + activeDialogStatus: state.status, + ...(activeInstall.status === "installing" + ? { activeInstallStage: activeInstall.stage } + : {}), + }), + ); } publish({ status: "confirming", version }); diff --git a/apps/web/src/commandPaletteContext.tsx b/apps/web/src/commandPaletteContext.tsx new file mode 100644 index 00000000000..8dae5fed3b5 --- /dev/null +++ b/apps/web/src/commandPaletteContext.tsx @@ -0,0 +1,29 @@ +import { createContext, use, type ReactNode } from "react"; + +const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null); + +export function OpenAddProjectCommandPaletteProvider(props: { + readonly children: ReactNode; + readonly openAddProject: () => void; +}) { + return ( + + {props.children} + + ); +} + +export function useOpenAddProjectCommandPalette(): () => void { + const openAddProject = use(OpenAddProjectCommandPaletteContext); + if (!openAddProject) { + throw new Error("Command palette actions must be used inside CommandPalette"); + } + return openAddProject; +} + +/** Read at event time so the chat tree does not subscribe to transient dialog state. */ +export function isCommandPaletteOpen(): boolean { + return ( + typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null + ); +} diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts deleted file mode 100644 index 04b25529f2f..00000000000 --- a/apps/web/src/commandPaletteStore.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { create } from "zustand"; - -interface CommandPaletteOpenIntent { - kind: "add-project"; - requestId: number; -} - -interface CommandPaletteStore { - open: boolean; - openIntent: CommandPaletteOpenIntent | null; - setOpen: (open: boolean) => void; - toggleOpen: () => void; - openAddProject: () => void; - clearOpenIntent: () => void; -} - -export const useCommandPaletteStore = create((set) => ({ - open: false, - openIntent: null, - setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }), - toggleOpen: () => - set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })), - openAddProject: () => - set((state) => ({ - open: true, - openIntent: { - kind: "add-project", - requestId: (state.openIntent?.requestId ?? 0) + 1, - }, - })), - clearOpenIntent: () => set({ openIntent: null }), -})); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d98f30a1e5c..0f1a8f9d429 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,40 +1,64 @@ -import { useEffect, type ReactNode } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { useEffect, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; +import { isElectron } from "../env"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { isMacPlatform } from "../lib/utils"; +import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; -import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; -import { - clearShortcutModifierState, - syncShortcutModifierStateFromKeyboardEvent, -} from "../shortcutModifierState"; +import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; -export function AppSidebarLayout({ children }: { children: ReactNode }) { - const navigate = useNavigate(); +const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; + +function SidebarControl() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { toggleSidebar } = useSidebar(); + const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - syncShortcutModifierStateFromKeyboardEvent(event); - }; - const onWindowKeyUp = (event: KeyboardEvent) => { - syncShortcutModifierStateFromKeyboardEvent(event); - }; - const onWindowBlur = () => { - clearShortcutModifierState(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; + + event.preventDefault(); + event.stopPropagation(); + toggleSidebar(); }; - window.addEventListener("keydown", onWindowKeyDown, true); - window.addEventListener("keyup", onWindowKeyUp, true); - window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, toggleSidebar]); - return () => { - window.removeEventListener("keydown", onWindowKeyDown, true); - window.removeEventListener("keyup", onWindowKeyUp, true); - window.removeEventListener("blur", onWindowBlur); - }; - }, []); + return ( +
+ + + } + /> + + Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + + +
+ ); +} + +export function AppSidebarLayout({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const macosWindowControlsStyle = + isElectron && isMacPlatform(navigator.platform) + ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) + : undefined; useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -54,7 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + {children} + ); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 27c5c311c60..03f24dac8e9 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,4 +1,4 @@ -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, @@ -11,9 +11,8 @@ import { import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useProject, useThread } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; -import { useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { type EnvMode, type EnvironmentOption, @@ -46,6 +45,8 @@ interface BranchToolbarProps { effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; + startFromOrigin: boolean; + onStartFromOriginChange: (startFromOrigin: boolean) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -197,6 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, + startFromOrigin, + onStartFromOriginChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -207,8 +210,7 @@ export const BranchToolbar = memo(function BranchToolbar({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); - const serverThread = useStore(serverThreadSelector); + const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); @@ -217,21 +219,17 @@ export const BranchToolbar = memo(function BranchToolbar({ : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const activeProjectSelector = useMemo( - () => createProjectSelectorByRef(activeProjectRef), - [activeProjectRef], - ); - const activeProject = useStore(activeProjectSelector); - const hasActiveThread = serverThread !== undefined || draftThread !== null; + const activeProject = useProject(activeProjectRef); + const hasActiveThread = serverThread !== null || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ activeWorktreePath, - hasServerThread: serverThread !== undefined, + hasServerThread: serverThread !== null, draftThreadEnvMode: draftThread?.envMode, }); - const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); + const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, @@ -285,6 +283,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 72391f714fc..e2ee24c3608 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,11 +1,16 @@ -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { useCallback, useDeferredValue, useEffect, + useId, useLayoutEffect, useMemo, useOptimistic, @@ -15,15 +20,16 @@ import { } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { readEnvironmentApi } from "../environmentApi"; -import { useVcsStatus } from "../lib/vcsStatusState"; -import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState"; -import { newCommandId } from "../lib/utils"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { usePaginatedBranches } from "../state/queries"; +import { useProject, useThread } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; -import { useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchSelectionTarget, @@ -32,7 +38,13 @@ import { resolveEffectiveEnvMode, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; +import { + ChangeRequestStatusIcon, + prStatusIndicator, + resolveThreadPr, +} from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; import { Combobox, ComboboxEmpty, @@ -44,6 +56,7 @@ import { ComboboxTrigger, } from "./ui/combobox"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; interface BranchToolbarBranchSelectorProps { className?: string; @@ -54,12 +67,12 @@ interface BranchToolbarBranchSelectorProps { effectiveEnvModeOverride?: "local" | "worktree"; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (refName: string | null) => void; + startFromOrigin: boolean; + onStartFromOriginChange: (startFromOrigin: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } -const EMPTY_REFS: ReadonlyArray = []; - function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } @@ -88,9 +101,23 @@ export function BranchToolbarBranchSelector({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, + startFromOrigin, + onStartFromOriginChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const startFromOriginSwitchId = useId(); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); + const updateThreadMetadata = useAtomCommand( + threadEnvironment.updateMetadata, + "thread metadata update", + ); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { + reportFailure: false, + }); + const createRefMutation = useAtomCommand(vcsEnvironment.createRef, { + reportFailure: false, + }); // --------------------------------------------------------------------------- // Thread / project state (pushed down from parent to colocate with mutation) // --------------------------------------------------------------------------- @@ -98,10 +125,8 @@ export function BranchToolbarBranchSelector({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); - const serverThread = useStore(serverThreadSelector); + const serverThread = useThread(threadRef); const serverSession = serverThread?.session ?? null; - const setThreadBranchAction = useStore((store) => store.setThreadBranch); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); @@ -112,11 +137,7 @@ export function BranchToolbarBranchSelector({ : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const activeProjectSelector = useMemo( - () => createProjectSelectorByRef(activeProjectRef), - [activeProjectRef], - ); - const activeProject = useStore(activeProjectSelector); + const activeProject = useProject(activeProjectRef); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = @@ -124,9 +145,9 @@ export function BranchToolbarBranchSelector({ ? activeThreadBranchOverride : (serverThread?.branch ?? draftThread?.branch ?? null); const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const activeProjectCwd = activeProject?.cwd ?? null; + const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; - const hasServerThread = serverThread !== undefined; + const hasServerThread = serverThread !== null; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ @@ -141,29 +162,24 @@ export function BranchToolbarBranchSelector({ const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { if (!activeThreadId || !activeProject) return; - const api = readEnvironmentApi(environmentId); - if (serverSession && worktreePath !== activeWorktreePath && api) { - void api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: activeThreadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); + if (serverSession && worktreePath !== activeWorktreePath) { + void stopThreadSession({ + environmentId, + input: { threadId: activeThreadId }, + }); } - if (api && hasServerThread) { - void api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, + if (hasServerThread) { + void updateThreadMetadata({ + environmentId, + input: { + threadId: activeThreadId, + branch, + worktreePath, + }, }); } if (hasServerThread) { onActiveThreadBranchOverrideChange?.(branch); - setThreadBranchAction(threadRef, branch, worktreePath); return; } const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ @@ -185,12 +201,13 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, hasServerThread, onActiveThreadBranchOverrideChange, - setThreadBranchAction, setDraftThreadContext, draftId, threadRef, environmentId, effectiveEnvMode, + stopThreadSession, + updateThreadMetadata, ], ); @@ -201,7 +218,14 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd }); + const branchStatusQuery = useEnvironmentQuery( + branchCwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd: branchCwd }, + }), + ); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); const branchRefTarget = useMemo( @@ -212,11 +236,11 @@ export function BranchToolbarBranchSelector({ }), [branchCwd, deferredTrimmedBranchQuery, environmentId], ); - const branchRefState = useVcsRefs(branchRefTarget); - const refs = branchRefState.data?.refs ?? EMPTY_REFS; + const branchRefState = usePaginatedBranches(branchRefTarget); + const refs = branchRefState.refs; const hasNextPage = branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; - const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); + const isFetchingNextPage = branchRefState.isPending && branchRefState.data !== null; const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; const currentGitBranch = branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; @@ -295,16 +319,14 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { - await action().catch(() => undefined); - await vcsRefManager - .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) - .catch(() => undefined); + await action(); + branchRefState.refresh(); + branchStatusQuery.refresh(); }); }; const selectBranch = (refName: VcsRef) => { - const api = readEnvironmentApi(environmentId); - if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; + if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { setThreadBranch(refName.name, null); @@ -336,23 +358,28 @@ export function BranchToolbarBranchSelector({ runBranchAction(async () => { const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); - try { - const checkoutResult = await api.vcs.switchRef({ + const checkoutResult = await switchRef({ + environmentId, + input: { cwd: selectionTarget.checkoutCwd, refName: refName.name, - }); + }, + }); + if (checkoutResult._tag === "Success") { const nextBranchName = refName.isRemote - ? (checkoutResult.refName ?? selectedBranchName) + ? (checkoutResult.value.refName ?? selectedBranchName) : selectedBranchName; setOptimisticBranch(nextBranchName); setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); - } catch (error) { - setOptimisticBranch(previousBranch); + return; + } + setOptimisticBranch(previousBranch); + if (!isAtomCommandInterrupted(checkoutResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to switch ref.", - description: toBranchActionErrorMessage(error), + description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), }), ); } @@ -361,8 +388,7 @@ export function BranchToolbarBranchSelector({ const createRef = (rawName: string) => { const name = rawName.trim(); - const api = readEnvironmentApi(environmentId); - if (!api || !branchCwd || !name || isBranchActionPending) return; + if (!branchCwd || !name || isBranchActionPending) return; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); @@ -370,21 +396,26 @@ export function BranchToolbarBranchSelector({ runBranchAction(async () => { const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); - try { - const createBranchResult = await api.vcs.createRef({ + const createBranchResult = await createRefMutation({ + environmentId, + input: { cwd: branchCwd, refName: name, switchRef: true, - }); - setOptimisticBranch(createBranchResult.refName); - setThreadBranch(createBranchResult.refName, activeWorktreePath); - } catch (error) { - setOptimisticBranch(previousBranch); + }, + }); + if (createBranchResult._tag === "Success") { + setOptimisticBranch(createBranchResult.value.refName); + setThreadBranch(createBranchResult.value.refName, activeWorktreePath); + return; + } + setOptimisticBranch(previousBranch); + if (!isAtomCommandInterrupted(createBranchResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to create and switch ref.", - description: toBranchActionErrorMessage(error), + description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)), }), ); } @@ -413,11 +444,9 @@ export function BranchToolbarBranchSelector({ setBranchQuery(""); return; } - void vcsRefManager - .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) - .catch(() => undefined); + branchRefState.refresh(); }, - [branchRefTarget], + [branchRefState.refresh], ); const branchListScrollElementRef = useRef(null); @@ -428,12 +457,8 @@ export function BranchToolbarBranchSelector({ return; } - setIsFetchingNextPage(true); - void vcsRefManager - .loadNext(branchRefTarget, undefined, { limit: 100 }) - .catch(() => undefined) - .finally(() => setIsFetchingNextPage(false)); - }, [branchRefTarget, hasNextPage, isFetchingNextPage]); + branchRefState.loadNext(); + }, [branchRefState.loadNext, hasNextPage, isFetchingNextPage]); const maybeFetchNextBranchPage = useCallback(() => { if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { return; @@ -465,14 +490,6 @@ export function BranchToolbarBranchSelector({ setShowBottomBranchScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); }, []); - useEffect(() => { - if (isBranchMenuOpen) { - return; - } - setShowTopBranchScrollFade(false); - setShowBottomBranchScrollFade(false); - }, [isBranchMenuOpen]); - useLayoutEffect(() => { if (!isBranchMenuOpen) { return; @@ -501,7 +518,7 @@ export function BranchToolbarBranchSelector({ return; } - branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); useEffect(() => { @@ -514,6 +531,16 @@ export function BranchToolbarBranchSelector({ resolvedActiveBranch, }); + // PR pill shown next to the branch selector when the active branch has one. + const branchPr = resolveThreadPr(resolvedActiveBranch, branchStatusQuery.data ?? null); + const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); + // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's + // state-description tooltip. + const branchPrTooltip = branchPr + ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state}) in browser` + : ""; + const openPrLink = useOpenPrLink(); + function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( @@ -601,7 +628,7 @@ export function BranchToolbarBranchSelector({ if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; } - branchListRef.current?.scrollIndexIntoView?.({ + void branchListRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false, }); @@ -610,15 +637,38 @@ export function BranchToolbarBranchSelector({ open={isBranchMenuOpen} value={resolvedActiveBranch} > - } - className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} - disabled={isInitialBranchesLoadPending || isBranchActionPending} - > - - {triggerLabel} - - +
+ {branchPr && branchPrStatus ? ( + + openPrLink(event, branchPrStatus.url)} + className={cn( + "inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium tabular-nums transition-colors hover:bg-muted/60", + branchPrStatus.colorClass, + )} + /> + } + > + + #{branchPr.number} + + {branchPrTooltip} + + ) : null} + } + className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" + disabled={isInitialBranchesLoadPending || isBranchActionPending} + > + + {triggerLabel} + + +
@@ -646,6 +696,13 @@ export function BranchToolbarBranchSelector({ ref={branchListRef} data={filteredBranchPickerItems} keyExtractor={(item) => item} + getItemType={(item) => + item === checkoutPullRequestItemValue + ? "checkout-pull-request" + : item === createBranchItemValue + ? "create-branch" + : "branch" + } renderItem={({ item, index }) => renderPickerItem(item, index)} estimatedItemSize={28} drawDistance={336} @@ -671,6 +728,34 @@ export function BranchToolbarBranchSelector({ />
+ {isSelectingWorktreeBase ? ( + + + + + onStartFromOriginChange(Boolean(checked))} + /> + + } + /> + + Creates the worktree from the latest matching branch on origin instead of your local + branch. + + + ) : null} {branchStatusText ? {branchStatusText} : null}
diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx deleted file mode 100644 index 7d5fddb6e29..00000000000 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ /dev/null @@ -1,892 +0,0 @@ -import "../index.css"; - -import { page } from "vite-plus/test/browser"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -const { - contextMenuShowMock, - openFileInPreviewMock, - openInPreferredEditorMock, - openUrlInPreviewMock, - readLocalApiMock, -} = vi.hoisted(() => ({ - contextMenuShowMock: vi.fn(), - openFileInPreviewMock: vi.fn(async () => undefined), - openInPreferredEditorMock: vi.fn(async () => "vscode"), - openUrlInPreviewMock: vi.fn(async () => undefined), - readLocalApiMock: vi.fn(() => ({ - contextMenu: { show: contextMenuShowMock }, - server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, - shell: { - openExternal: vi.fn(async () => undefined), - openInEditor: vi.fn(async () => undefined), - }, - })), -})); - -vi.mock("../editorPreferences", () => ({ - openInPreferredEditor: openInPreferredEditorMock, -})); - -vi.mock("../localApi", () => ({ - ensureLocalApi: vi.fn(() => { - throw new Error("ensureLocalApi not implemented in browser test"); - }), - readLocalApi: readLocalApiMock, -})); - -vi.mock("../previewStateStore", async (importOriginal) => ({ - ...(await importOriginal()), - isPreviewSupportedInRuntime: () => true, -})); - -vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ - ...(await importOriginal()), - openFileInPreview: openFileInPreviewMock, - openUrlInPreview: openUrlInPreviewMock, -})); - -import ChatMarkdown from "./ChatMarkdown"; -import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; - -const threadRef = { - environmentId: EnvironmentId.make("environment-test"), - threadId: ThreadId.make("thread-test"), -}; - -describe("ChatMarkdown", () => { - afterEach(() => { - openInPreferredEditorMock.mockClear(); - openFileInPreviewMock.mockClear(); - openUrlInPreviewMock.mockClear(); - contextMenuShowMock.mockReset(); - readLocalApiMock.mockClear(); - useRightPanelStore.setState({ byThreadKey: {} }); - localStorage.clear(); - document.body.innerHTML = ""; - }); - - it("makes task-list checkboxes interactive when a change handler is provided", async () => { - const onTaskListChange = vi.fn(); - const screen = await render( - , - ); - - try { - const checkbox = page.getByRole("checkbox", { name: "Toggle task" }); - await expect.element(checkbox).not.toBeDisabled(); - await checkbox.click(); - expect(onTaskListChange).toHaveBeenCalledWith({ markerOffset: 2, checked: true }); - } finally { - await screen.unmount(); - } - }); - - it("rewrites file uri hrefs into direct paths before rendering", async () => { - const filePath = - "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "PermissionRule.ts" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", filePath); - - await link.click(); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); - }); - } finally { - await screen.unmount(); - } - }); - - it("keeps line anchors working after rewriting file uri hrefs", async () => { - const filePath = - "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}:1`); - - await link.click(); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:1`); - }); - } finally { - await screen.unmount(); - } - }); - - it("shows column information inline when present", async () => { - const filePath = - "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}:1:7`); - - await link.click(); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith( - expect.anything(), - `${filePath}:1:7`, - ); - }); - } finally { - await screen.unmount(); - } - }); - - it("disambiguates duplicate file basenames inline", async () => { - const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx"; - const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx"; - const screen = await render( - , - ); - - try { - await expect - .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" })) - .toBeInTheDocument(); - await expect - .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" })) - .toBeInTheDocument(); - } finally { - await screen.unmount(); - } - }); - - it("keeps normal web links unchanged", async () => { - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "OpenAI" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); - await expect.element(link).toHaveAttribute("target", "_blank"); - const favicon = link.element().querySelector(".chat-markdown-link-favicon"); - const leading = link.element().querySelector(".chat-markdown-link-leading"); - expect(favicon).not.toBeNull(); - expect(leading).not.toBeNull(); - expect(leading?.contains(favicon)).toBe(true); - expect(getComputedStyle(leading!).display).toBe("inline"); - expect(getComputedStyle(leading!).whiteSpace).toBe("nowrap"); - expect(getComputedStyle(favicon!).verticalAlign).not.toBe("baseline"); - expect(leading?.textContent).toBe("O"); - expect(link.element().textContent).toBe("OpenAI"); - expect(getComputedStyle(link.element()).textDecorationLine).toBe("none"); - expect(link.element().querySelector("img, svg")?.getBoundingClientRect().width).toBe(14); - await link.hover(); - expect(getComputedStyle(link.element()).backgroundImage).not.toBe("none"); - await expect.element(page.getByText("https://openai.com/docs")).toBeVisible(); - } finally { - await screen.unmount(); - } - }); - - it("opens web links in the integrated browser from the context menu", async () => { - contextMenuShowMock.mockResolvedValue("open-in-browser"); - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "OpenAI" }).element(); - link.dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: 12, - clientY: 24, - }), - ); - - await vi.waitFor(() => { - expect(contextMenuShowMock).toHaveBeenCalled(); - expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs"); - }); - } finally { - await screen.unmount(); - } - }); - - it("offers integrated browser opening for HTML file links", async () => { - contextMenuShowMock.mockResolvedValue("open-in-browser"); - const filePath = "/repo/project/report.html"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "report.html" }).element(); - link.dispatchEvent( - new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }), - ); - - await vi.waitFor(() => { - expect(contextMenuShowMock).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - id: "open-in-browser", - label: "Open in integrated browser", - }), - ]), - { x: 4, y: 8 }, - ); - expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath); - }); - } finally { - await screen.unmount(); - } - }); - - it("opens code file links in the right-panel file preview", async () => { - const screen = await render( - , - ); - - try { - await page.getByRole("link", { name: "ChatMarkdown.tsx · L978" }).click(); - - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef), - ).toMatchObject({ - isOpen: true, - activeSurfaceId: "file:apps/web/src/components/ChatMarkdown.tsx", - surfaces: [ - expect.objectContaining({ - relativePath: "apps/web/src/components/ChatMarkdown.tsx", - revealLine: 978, - revealRequestId: 1, - }), - ], - }); - expect(openInPreferredEditorMock).not.toHaveBeenCalled(); - expect(openFileInPreviewMock).not.toHaveBeenCalled(); - }); - } finally { - await screen.unmount(); - } - }); - - it("opens HTML and PDF file links in the integrated browser preview", async () => { - const screen = await render( - , - ); - - try { - await page.getByRole("link", { name: "report.html" }).click(); - await page.getByRole("link", { name: "report.pdf" }).click(); - - await vi.waitFor(() => { - expect(openFileInPreviewMock).toHaveBeenNthCalledWith( - 1, - threadRef, - "/repo/project/report.html", - ); - expect(openFileInPreviewMock).toHaveBeenNthCalledWith( - 2, - threadRef, - "/repo/project/report.pdf", - ); - expect(openInPreferredEditorMock).not.toHaveBeenCalled(); - }); - } finally { - await screen.unmount(); - } - }); - - it("keeps opening file links in the editor from the context menu", async () => { - contextMenuShowMock.mockResolvedValue("open"); - const filePath = "/repo/project/src/index.ts"; - const screen = await render( - , - ); - - try { - page - .getByRole("link", { name: "index.ts" }) - .element() - .dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: 4, - clientY: 8, - }), - ); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); - }); - } finally { - await screen.unmount(); - } - }); - - it("keeps a favicon with the leading segment of a wrapping URL", async () => { - const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; - const screen = await render( -
- -
, - ); - - try { - const link = page.getByRole("link", { name: url }); - const leading = link.element().querySelector(".chat-markdown-link-leading"); - const favicon = link.element().querySelector(".chat-markdown-link-favicon"); - expect(leading).not.toBeNull(); - expect(favicon).not.toBeNull(); - expect(leading?.contains(favicon)).toBe(true); - expect(leading?.textContent).toBe("https://"); - expect(getComputedStyle(leading!).display).toBe("inline"); - expect(getComputedStyle(leading!).whiteSpace).toBe("nowrap"); - expect(getComputedStyle(favicon!).verticalAlign).not.toBe("baseline"); - expect(link.element().textContent).toBe(url); - expect(link.element().querySelectorAll("wbr").length).toBeGreaterThan(0); - const markdownRoot = link.element().closest(".chat-markdown"); - expect(markdownRoot).not.toBeNull(); - expect(markdownRoot!.scrollWidth).toBeLessThanOrEqual(markdownRoot!.clientWidth); - } finally { - await screen.unmount(); - } - }); - - it("renders file links with the shared file tag chip treatment", async () => { - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "package.json" }); - await expect.element(link).toHaveClass(/chat-markdown-file-link/); - const element = document.querySelector(".chat-markdown-file-link"); - expect(element?.querySelector("img, svg")).not.toBeNull(); - expect(getComputedStyle(element!).display).toBe("inline-flex"); - expect(getComputedStyle(element!).textDecorationLine).toBe("none"); - expect(getComputedStyle(element!).borderStyle).toBe("solid"); - expect(getComputedStyle(element!).userSelect).not.toBe("none"); - } finally { - await screen.unmount(); - } - }); - - it("renders sanitized details with the design-system collapsible", async () => { - const source = [ - "
", - "Expandable details section", - "", - "This content includes **formatted text**.", - "", - 'Safe inline HTML', - "", - "
", - ].join("\n"); - const screen = await render(); - - try { - const details = document.querySelector("[data-markdown-details]"); - const trigger = page.getByRole("button", { name: "Expandable details section" }); - expect(details).not.toBeNull(); - expect(details?.tagName).toBe("DIV"); - await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); - expect(details?.querySelector("strong")?.textContent).toBe("formatted text"); - expect(details?.querySelector("script")).toBeNull(); - expect(details?.querySelector("[title]")).toBeNull(); - - await trigger.click(); - await expect.element(trigger).toHaveAttribute("aria-expanded", "false"); - await trigger.click(); - await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); - } finally { - await screen.unmount(); - } - }); - - it("renders footnotes as same-document references", async () => { - const source = [ - "A claim with supporting context.[^context]", - "", - "[^context]: Supporting **footnote text**.", - ].join("\n"); - const screen = await render(); - - try { - const reference = document.querySelector( - '.chat-markdown a[data-footnote-ref=""]', - ); - const footnotes = document.querySelector( - ".chat-markdown section[data-footnotes]", - ); - expect(reference).not.toBeNull(); - expect(reference?.getAttribute("href")).toMatch(/^#user-content-fn-/); - expect(reference?.hasAttribute("target")).toBe(false); - expect(footnotes).not.toBeNull(); - expect(footnotes?.querySelector("strong")?.textContent).toBe("footnote text"); - expect(footnotes?.querySelector("a[data-footnote-backref]")?.target).toBe( - "", - ); - } finally { - await screen.unmount(); - } - }); - - it("navigates hash links within the clicked markdown message", async () => { - const source = [ - "A claim with supporting context.[^context]", - "", - "[^context]: Supporting footnote text.", - ].join("\n"); - const originalUrl = window.location.href; - const scrollIntoView = vi - .spyOn(HTMLElement.prototype, "scrollIntoView") - .mockImplementation(() => undefined); - const screen = await render( -
- - -
, - ); - - try { - const markdownRoots = document.querySelectorAll(".chat-markdown"); - const secondRoot = markdownRoots[1]; - const secondReference = - secondRoot?.querySelector('a[data-footnote-ref=""]'); - const secondFootnote = secondRoot?.querySelector( - "section[data-footnotes] li[id]", - ); - expect(secondReference).not.toBeNull(); - expect(secondFootnote).not.toBeNull(); - - secondReference?.click(); - - expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(scrollIntoView.mock.instances[0]).toBe(secondFootnote); - expect(window.location.hash).toBe(secondReference?.hash); - - const secondBackref = secondRoot?.querySelector( - "a[data-footnote-backref]", - ); - expect(secondBackref).not.toBeNull(); - secondBackref?.click(); - - const secondReferenceTarget = secondReference?.closest("[id]"); - expect(scrollIntoView).toHaveBeenCalledTimes(2); - expect(scrollIntoView.mock.instances[1]).toBe(secondReferenceTarget); - } finally { - scrollIntoView.mockRestore(); - window.history.replaceState(window.history.state, "", originalUrl); - await screen.unmount(); - } - }); - - describe("code block chrome", () => { - it("shows icon-only language titles, text fallbacks, and filename overrides", async () => { - const source = [ - "```ts", - "const a = 1;", - "```", - "", - '```ts title="src/main.ts"', - "const b = 2;", - "```", - "", - "```text", - "plain", - "```", - ].join("\n"); - const screen = await render(); - - try { - const titles = [...document.querySelectorAll(".chat-markdown-codeblock-title")]; - expect(titles).toHaveLength(3); - - // Language with a known icon: icon XOR text — never the redundant pair. - const languageOnly = titles[0]!; - const hasIcon = languageOnly.querySelector("svg[data-pierre-icon]") != null; - const hasText = (languageOnly.textContent ?? "").includes("ts"); - expect(hasIcon || hasText).toBe(true); - expect(hasIcon && hasText).toBe(false); - if (hasIcon) { - const languageTrigger = page.getByLabelText("Language: ts").first(); - await languageTrigger.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("ts"); - }); - } - - // Explicit filename: text always shown. - expect(titles[1]!.textContent).toBe("src/main.ts"); - - // Unknown language: no icon attempt, text label. - expect(titles[2]!.querySelector("svg[data-pierre-icon]")).toBeNull(); - expect(titles[2]!.textContent).toBe("text"); - } finally { - await screen.unmount(); - } - }); - - it("toggles line wrapping per block", async () => { - const screen = await render( - , - ); - - try { - const block = document.querySelector(".chat-markdown-codeblock"); - expect(block?.getAttribute("data-wrap")).toBe("false"); - - const toggle = page.getByRole("button", { name: "Wrap lines" }); - await expect.element(toggle).not.toHaveAttribute("title"); - await toggle.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("Wrap lines"); - }); - await toggle.click(); - expect(block?.getAttribute("data-wrap")).toBe("true"); - - await page.getByRole("button", { name: "Disable line wrap" }).click(); - expect(block?.getAttribute("data-wrap")).toBe("false"); - } finally { - await screen.unmount(); - } - }); - }); - - it("scrolls wide tables horizontally instead of letter-wrapping cells", async () => { - const header = `| ${Array.from({ length: 8 }, (_, i) => `ColumnHeading${i}`).join(" | ")} |`; - const separator = `| ${Array.from({ length: 8 }, () => "---").join(" | ")} |`; - const row = `| ${Array.from({ length: 8 }, () => "averylongunbrokencellvalue@example-domain.com").join(" | ")} |`; - const screen = await render( - , - ); - - try { - const viewport = document.querySelector( - '.chat-markdown-table-container [data-slot="scroll-area-viewport"]', - ); - expect(viewport).not.toBeNull(); - expect(viewport!.querySelector("table")).not.toBeNull(); - // Content exceeds the container — the scroll-fade viewport scrolls - // horizontally rather than squishing columns. - expect(viewport!.scrollWidth).toBeGreaterThan(viewport!.clientWidth); - // And cells keep their longest word intact instead of breaking mid-word. - const cell = viewport!.querySelector("td"); - expect(cell!.getBoundingClientRect().width).toBeGreaterThan(100); - } finally { - await screen.unmount(); - } - }); - - describe("table chrome", () => { - const longCell = - "This service has been experiencing intermittent latency spikes during peak traffic hours and the on-call team is investigating."; - - it("truncates cells by default and expands them from the footer toggle", async () => { - const source = ["| Name | Notes |", "| --- | --- |", `| api | ${longCell} |`].join("\n"); - const screen = await render(); - - try { - const container = document.querySelector(".chat-markdown-table-container"); - expect(container?.getAttribute("data-expanded")).toBe("false"); - - const noteCell = [...document.querySelectorAll(".chat-markdown td")].at(-1)!; - expect(getComputedStyle(noteCell).whiteSpace).toBe("nowrap"); - expect(noteCell.scrollWidth).toBeGreaterThan(noteCell.clientWidth); - - const expandButton = page.getByRole("button", { name: "Expand table cells" }); - await expect.element(expandButton).not.toHaveAttribute("title"); - await expandButton.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("Expand table cells"); - }); - await expandButton.click(); - expect(container?.getAttribute("data-expanded")).toBe("true"); - expect(getComputedStyle(noteCell).whiteSpace).not.toBe("nowrap"); - - await page.getByRole("button", { name: "Collapse table cells" }).click(); - expect(container?.getAttribute("data-expanded")).toBe("false"); - - const copyButton = page.getByRole("button", { name: "Copy table" }); - await expect.element(copyButton).not.toHaveAttribute("title"); - await copyButton.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("Copy table"); - }); - expect(document.querySelector(".chat-markdown [title]")).toBeNull(); - } finally { - await screen.unmount(); - } - }); - - it("retains column widths when cells expand", async () => { - const source = [ - "| ID | Owner | Status | Priority | Region | Summary | Long Description | Metrics | Payload | Notes |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", - '| 1001 | Ada Lovelace | Active | High | us-west-2 | Payment workflow migration | This cell has enough text to wrap across several lines when expanded without shrinking its column. | Requests: 128,440; Error rate: 0.04%; P95: 212ms | `{ "feature": "billing", "version": 3 }` | Needs post-release monitoring for 24 hours. |', - ].join("\n"); - const screen = await render(); - - try { - const viewport = document.querySelector( - '.chat-markdown-table-container [data-slot="scroll-area-viewport"]', - )!; - const table = viewport.querySelector("table")!; - const collapsedWidths = [...table.querySelectorAll("thead th")].map( - (cell) => cell.getBoundingClientRect().width, - ); - expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); - - await page.getByRole("button", { name: "Expand table cells" }).click(); - - const expandedWidths = [...table.querySelectorAll("thead th")].map( - (cell) => cell.getBoundingClientRect().width, - ); - expect(expandedWidths).toHaveLength(collapsedWidths.length); - expandedWidths.forEach((width, index) => { - expect(width).toBeGreaterThanOrEqual(collapsedWidths[index]! - 1); - }); - expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); - } finally { - await screen.unmount(); - } - }); - - it("exports tables as markdown and csv", async () => { - const source = [ - "| Name | Count |", - "| --- | ---: |", - '| widget, "deluxe" | 2 |', - "| plain | 1 |", - ].join("\n"); - const screen = await render(); - - try { - const table = document.querySelector(".chat-markdown table")!; - expect(serializeTableElementToMarkdown(table)).toBe( - ["| Name | Count |", "| --- | ---: |", '| widget, "deluxe" | 2 |', "| plain | 1 |"].join( - "\n", - ), - ); - expect(serializeTableElementToCsv(table)).toBe( - ["Name,Count", '"widget, ""deluxe""",2', "plain,1"].join("\n"), - ); - } finally { - await screen.unmount(); - } - }); - }); - - describe("copying rendered markdown", () => { - function copySelectedMarkdown(): { text: string; html: string } { - const root = document.querySelector(".chat-markdown"); - if (!root) throw new Error("chat-markdown root not rendered"); - const selection = window.getSelection(); - if (!selection) throw new Error("selection unavailable"); - selection.removeAllRanges(); - const range = document.createRange(); - range.selectNodeContents(root); - selection.addRange(range); - - const clipboardData = new DataTransfer(); - root.dispatchEvent( - new ClipboardEvent("copy", { clipboardData, bubbles: true, cancelable: true }), - ); - selection.removeAllRanges(); - return { - text: clipboardData.getData("text/plain"), - html: clipboardData.getData("text/html"), - }; - } - - it("round-trips links, emphasis, and inline code", async () => { - const screen = await render( - , - ); - - try { - const { text, html } = copySelectedMarkdown(); - expect(text).toBe( - "Check out [Anthropic](https://anthropic.com), **bold**, *italic*, and `code`.", - ); - expect(html).toContain('href="https://anthropic.com"'); - } finally { - await screen.unmount(); - } - }); - - it("round-trips block structure: headings, lists, quotes, and fences", async () => { - const source = [ - "## Heading", - "", - "- first", - "- second", - " - nested", - "", - "1. one", - "2. two", - "", - "- [x] done", - "- [ ] todo", - "", - "> quoted", - "", - "```ts", - "const x = 1;", - "", - "const y = 2;", - "```", - ].join("\n"); - const screen = await render(); - - try { - const { text } = copySelectedMarkdown(); - expect(text).toBe(source); - } finally { - await screen.unmount(); - } - }); - - it("round-trips tables with alignment", async () => { - const source = ["| Name | Count |", "| --- | ---: |", "| a | 1 |", "| b | 2 |"].join("\n"); - const screen = await render(); - - try { - const { text } = copySelectedMarkdown(); - expect(text).toBe(source); - } finally { - await screen.unmount(); - } - }); - - it("round-trips details rendered through the collapsible", async () => { - const source = [ - "
", - "Expandable details section", - "", - "This content includes **formatted text**.", - "
", - ].join("\n"); - const screen = await render(); - - try { - const { text } = copySelectedMarkdown(); - expect(text).toBe(source); - } finally { - await screen.unmount(); - } - }); - - it("excludes the code block header chrome from copied markdown", async () => { - const source = ["```ts", "const x = 1;", "```"].join("\n"); - const screen = await render(); - - try { - const { text } = copySelectedMarkdown(); - expect(text).toBe(source); - } finally { - await screen.unmount(); - } - }); - - it("copies file links as markdown and skips UI affordances", async () => { - const filePath = "/Users/yashsingh/p/t3code/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const { text, html } = copySelectedMarkdown(); - expect(text).toBe( - `See [PermissionRule.ts](/Users/yashsingh/p/t3code/src/utils/permissions/PermissionRule.ts) for details.`, - ); - expect(html).toContain("PermissionRule.ts"); - expect(html).not.toContain(" { - const source = - "Use $agent-browser with [package.json](path/to/package.json) before continuing."; - const screen = await render( - , - ); - - try { - const root = document.querySelector(".chat-markdown")!; - const selection = window.getSelection()!; - selection.removeAllRanges(); - const range = document.createRange(); - range.selectNodeContents(root); - selection.addRange(range); - expect(selection.toString()).toContain("Agent Browser"); - expect(selection.toString()).toContain("package.json"); - selection.removeAllRanges(); - - const { text, html } = copySelectedMarkdown(); - expect(text).toBe(source); - expect(html).toContain("Agent Browser"); - expect(html).toContain("package.json"); - expect(html).not.toContain("( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, @@ -262,10 +293,14 @@ function getHighlighterPromise(language: string): Promise { return promise; } +function readInitialWordWrapSetting(): boolean { + return getClientSettings().wordWrap; +} + function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(readInitialWordWrapSetting); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; @@ -316,7 +351,9 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { copiedTimerRef.current = null; }, 1200); }) - .catch(() => undefined); + .catch((cause) => { + reportMarkdownActionFailure({ operation: "copy-table", format }, cause); + }); }, []); useEffect( @@ -493,10 +530,11 @@ function MarkdownCodeBlock({ children: ReactNode; }) { const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(false); + const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; + const handleCopy = useCallback(() => { if (typeof navigator === "undefined" || navigator.clipboard == null) { return; @@ -513,8 +551,17 @@ function MarkdownCodeBlock({ copiedTimerRef.current = null; }, 1200); }) - .catch(() => undefined); - }, [code]); + .catch((cause) => { + reportMarkdownActionFailure( + { + operation: "copy-code-block", + language, + ...(fenceTitle ? { fenceTitle } : {}), + }, + cause, + ); + }); + }, [code, fenceTitle, language]); useEffect( () => () => { @@ -676,6 +723,8 @@ interface MarkdownFileLinkProps { copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; + onOpen: (targetPath: string) => Promise>; + onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -942,54 +991,6 @@ function MarkdownExternalLinkContent({ ); } -function MarkdownExternalLink({ - href, - threadRef, - children, - ...props -}: React.ComponentProps<"a"> & { - href: string; - threadRef?: ScopedThreadRef | undefined; -}) { - const handleContextMenu = useCallback( - async (event: ReactMouseEvent) => { - if (!threadRef || !isPreviewSupportedInRuntime()) return; - event.preventDefault(); - event.stopPropagation(); - - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "open-in-browser", label: "Open in integrated browser" }, - { id: "open-external", label: "Open in system browser" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "open-in-browser") { - void openUrlInPreview(threadRef, href).catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open link in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }); - } else if (clicked === "open-external") { - void api.shell.openExternal(href); - } - }, - [href, threadRef], - ); - - return ( - - {children} - - ); -} - const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, @@ -1001,28 +1002,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ copyMarkdown, theme, threadRef, + onOpen, + onOpenInBrowser, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Open in editor is unavailable", - }); - return; - } - - void openInPreferredEditor(api, targetPath).catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }); - }, [targetPath]); + void (async () => { + try { + const result = await onOpen(targetPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "open-file-in-editor", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "open-file-in-editor", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + })(); + }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { if (!threadRef || !workspaceRelativePath) { @@ -1033,49 +1050,81 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { - if (!threadRef) return; - void openFileInPreview(threadRef, iconPath).catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }); - }, [iconPath, threadRef]); - - const handleCopy = useCallback((value: string, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); + if (!onOpenInBrowser) { return; } + void (async () => { + try { + const result = await onOpenInBrowser(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "open-file-in-browser", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "open-file-in-browser", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + })(); + }, [onOpenInBrowser, targetPath]); - void navigator.clipboard.writeText(value).then( - () => { - toastManager.add({ - type: "success", - title: `${title} copied`, - description: value, - }); - }, - (error) => { + const handleCopy = useCallback( + (value: string, title: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ type: "error", title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", + description: "Clipboard API unavailable.", }), ); - }, - ); - }, []); + return; + } + + void navigator.clipboard.writeText(value).then( + () => { + toastManager.add({ + type: "success", + title: `${title} copied`, + description: value, + }); + }, + (error) => { + reportMarkdownActionFailure( + { operation: "copy-file-path", target: targetPath, copyTarget: title }, + error, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }, + [targetPath], + ); const handleContextMenu = useCallback( async (event: ReactMouseEvent) => { @@ -1085,45 +1134,42 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; - const canOpenInBrowser = - Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath); - const clicked = await api.contextMenu.show( - [ - { id: "open", label: "Open in editor" }, - ...(canOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - { id: "copy-relative", label: "Copy relative path" }, - { id: "copy-full", label: "Copy full path" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); + try { + const clicked = await api.contextMenu.show( + [ + { id: "open", label: "Open in editor" }, + ...(onOpenInBrowser + ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + : []), + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); - if (clicked === "open") { - handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { - handleOpenInBrowser(); - return; - } - if (clicked === "copy-relative") { - handleCopy(displayPath, "Relative path"); - return; - } - if (clicked === "copy-full") { - handleCopy(targetPath, "Full path"); + if (clicked === "open") { + handleOpenInEditor(); + return; + } + if (clicked === "open-in-browser") { + handleOpenInBrowser(); + return; + } + if (clicked === "copy-relative") { + handleCopy(displayPath, "Relative path"); + return; + } + if (clicked === "copy-full") { + handleCopy(targetPath, "Full path"); + } + } catch (cause) { + reportMarkdownActionFailure( + { operation: "show-file-context-menu", target: targetPath }, + cause, + ); } }, - [ - displayPath, - handleCopy, - handleOpenInBrowser, - handleOpenInEditor, - iconPath, - targetPath, - threadRef, - ], + [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], ); return ( @@ -1137,7 +1183,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - if (threadRef && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath)) { + if (onOpenInBrowser) { handleOpenInBrowser(); return; } @@ -1175,8 +1221,9 @@ function areMarkdownFileLinkPropsEqual( previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && - previous.threadRef?.environmentId === next.threadRef?.environmentId && - previous.threadRef?.threadId === next.threadRef?.threadId && + previous.threadRef === next.threadRef && + previous.onOpen === next.onOpen && + previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); } @@ -1192,6 +1239,19 @@ function ChatMarkdown({ lineBreaks = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); + const environmentId = useActiveEnvironmentId(); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const openInPreferredEditor = useOpenInPreferredEditor( + environmentId, + serverConfig?.availableEditors ?? [], + ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< @@ -1226,6 +1286,46 @@ function ChatMarkdown({ event.clipboardData.setData("text/plain", payload.text); event.clipboardData.setData("text/html", payload.html); }, []); + const openExternalLinkInPreview = useCallback( + (url: string) => { + if (!threadRef) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + message: "Thread context is unavailable.", + }), + ), + ), + ); + } + return openUrlInPreview({ threadRef, url, openPreview }); + }, + [openPreview, threadRef], + ); + const openMarkdownFileInPreview = useCallback( + (path: string) => { + if (!threadRef || preparedConnection._tag === "None") { + return Promise.resolve( + AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + message: "Environment is not connected.", + }), + ), + ), + ); + } + return openFileInPreview({ + threadRef, + filePath: path, + httpBaseUrl: preparedConnection.value.httpBaseUrl, + createAssetUrl, + openPreview, + }); + }, + [createAssetUrl, openPreview, preparedConnection, threadRef], + ); const markdownComponents = useMemo( () => ({ p({ node: _node, children, ...props }) { @@ -1277,11 +1377,11 @@ function ChatMarkdown({ const faviconHost = resolveExternalLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; + const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); const link = ( - { @@ -1290,6 +1390,39 @@ function ChatMarkdown({ handleMarkdownFragmentClick(event, href); } }} + onContextMenu={(event) => { + if (!canOpenInPreview || !href) return; + event.preventDefault(); + event.stopPropagation(); + const api = readLocalApi(); + if (!api) return; + void (async () => { + let operation = "show-link-context-menu"; + try { + const clicked = await api.contextMenu.show( + [ + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); + if (clicked === "open-in-browser") { + operation = "open-link-in-preview"; + const result = await openExternalLinkInPreview(href); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure({ operation, target: href }, result.cause); + } + return; + } + if (clicked === "open-external") { + operation = "open-link-external"; + await api.shell.openExternal(href); + } + } catch (cause) { + reportMarkdownActionFailure({ operation, target: href }, cause); + } + })(); + }} > {faviconHost ? ( @@ -1298,7 +1431,7 @@ function ChatMarkdown({ ) : ( children )} - + ); if (!faviconHost || !href) { return link; @@ -1339,6 +1472,14 @@ function ChatMarkdown({ copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} theme={resolvedTheme} threadRef={threadRef} + onOpen={openInPreferredEditor} + onOpenInBrowser={ + threadRef && + isPreviewSupportedInRuntime() && + isBrowserPreviewFile(fileLinkMeta.filePath) + ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } className={props.className} /> ); @@ -1384,10 +1525,13 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, onTaskListChange, - threadRef, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, resolvedTheme, skills, text, + threadRef, ], ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx deleted file mode 100644 index f210c584834..00000000000 --- a/apps/web/src/components/ChatView.browser.tsx +++ /dev/null @@ -1,7456 +0,0 @@ -// Production CSS is part of the behavior under test because row height depends on it. -import "../index.css"; - -import { - EventId, - ORCHESTRATION_WS_METHODS, - EnvironmentId, - type EnvironmentApi, - type MessageId, - type OrchestrationReadModel, - type ProjectId, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type TerminalMetadataStreamEvent, - type ServerLifecycleWelcomePayload, - type ThreadId, - type TurnId, - WS_METHODS, - OrchestrationSessionStatus, - DEFAULT_SERVER_SETTINGS, - DEFAULT_TERMINAL_ID, - ServerConfig as ServerConfigSchema, -} from "@t3tools/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; -import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; -import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { HttpResponse, http, ws } from "msw"; -import { setupWorker } from "msw/browser"; -import { page } from "vite-plus/test/browser"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { useCommandPaletteStore } from "../commandPaletteStore"; -import { useComposerDraftStore, DraftId } from "../composerDraftStore"; -import { - __resetEnvironmentApiOverridesForTests, - __setEnvironmentApiOverrideForTests, -} from "../environmentApi"; -import { - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime/catalog"; -import { - INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - removeInlineTerminalContextPlaceholder, - type TerminalContextDraft, -} from "../lib/terminalContext"; -import { isMacPlatform } from "../lib/utils"; -import { __resetLocalApiForTests } from "../localApi"; -import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; -import { getServerConfig } from "../rpc/serverState"; -import { getRouter } from "../router"; -import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; -import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; -import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; -import { terminalSessionManager } from "../terminalSessionState"; -import { useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useUiStateStore } from "../uiStateStore"; -import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; -import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; - -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; - -vi.mock("../lib/vcsStatusState", () => { - const status = { - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: true, - refName: "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }, - error: null, - cause: null, - isPending: false, - }; - - return { - getVcsStatusDataForTarget: (state: typeof status) => state.data, - getVcsStatusSnapshot: () => status, - useVcsStatus: () => status, - useVcsStatuses: () => new Map(), - refreshVcsStatus: () => Promise.resolve(null), - resetVcsStatusStateForTests: () => undefined, - }; -}); - -const THREAD_ID = "thread-browser-test" as ThreadId; -const THREAD_TITLE = "Browser test thread"; -const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; -const PROJECT_ID = "project-1" as ProjectId; -const SECOND_PROJECT_ID = "project-2" as ProjectId; -const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); -const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); -const THREAD_KEY = scopedThreadKey(THREAD_REF); -const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( - { - environmentId: LOCAL_ENVIRONMENT_ID, - id: PROJECT_ID, - cwd: "/repo/project", - repositoryIdentity: null, - }, - { - sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, - }, -); -const NOW_ISO = "2026-03-04T12:00:00.000Z"; -const BASE_TIME_MS = Date.parse(NOW_ISO); -const ATTACHMENT_SVG = ""; -const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)"; - -interface TestFixture { - snapshot: OrchestrationReadModel; - serverConfig: ServerConfig; - welcome: ServerLifecycleWelcomePayload; - terminalMetadataEvents: ReadonlyArray; -} - -let fixture: TestFixture; -const rpcHarness = new BrowserWsRpcHarness(); -const wsRequests = rpcHarness.requests; -let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; -const wsLink = ws.link(/ws(s)?:\/\/.*/); -const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); - -interface ViewportSpec { - name: string; - width: number; - height: number; - textTolerancePx: number; - attachmentTolerancePx: number; -} - -const DEFAULT_VIEWPORT: ViewportSpec = { - name: "desktop", - width: 960, - height: 1_100, - textTolerancePx: 44, - attachmentTolerancePx: 56, -}; -const WIDE_FOOTER_VIEWPORT: ViewportSpec = { - name: "wide-footer", - width: 1_400, - height: 1_100, - textTolerancePx: 44, - attachmentTolerancePx: 56, -}; -const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { - name: "compact-footer", - width: 430, - height: 932, - textTolerancePx: 56, - attachmentTolerancePx: 56, -}; - -interface MountedChatView { - [Symbol.asyncDispose]: () => Promise; - cleanup: () => Promise; - setViewport: (viewport: ViewportSpec) => Promise; - setContainerSize: (viewport: Pick) => Promise; - router: ReturnType; -} - -function isoAt(offsetSeconds: number): string { - return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); -} - -function createBaseServerConfig(): ServerConfig { - return { - environment: { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/repo/project", - keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", - keybindings: [], - issues: [], - providers: [ - { - driver: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [], - slashCommands: [], - skills: [], - }, - ], - availableEditors: [], - observability: { - logsDirectoryPath: "/repo/project/.t3/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: { - ...DEFAULT_SERVER_SETTINGS, - ...DEFAULT_CLIENT_SETTINGS, - }, - }; -} - -function createMockEnvironmentApi(input: { - browse: EnvironmentApi["filesystem"]["browse"]; - dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; -}): EnvironmentApi { - return { - terminal: {} as EnvironmentApi["terminal"], - projects: {} as EnvironmentApi["projects"], - filesystem: { - browse: input.browse, - }, - assets: { - createUrl: vi.fn(async ({ resource }) => ({ - relativeUrl: `/api/assets/test/${encodeURIComponent( - resource._tag === "attachment" - ? resource.attachmentId - : resource._tag === "project-favicon" - ? "favicon.svg" - : (resource.path.split(/[\\/]/).at(-1) ?? "asset"), - )}`, - expiresAt: Date.now() + 60_000, - })), - }, - sourceControl: {} as EnvironmentApi["sourceControl"], - vcs: {} as EnvironmentApi["vcs"], - git: {} as EnvironmentApi["git"], - review: {} as EnvironmentApi["review"], - orchestration: { - dispatchCommand: input.dispatchCommand, - getTurnDiff: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getTurnDiff"], - getFullThreadDiff: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], - getArchivedShellSnapshot: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"], - subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], - subscribeThread: (() => () => - undefined) as EnvironmentApi["orchestration"]["subscribeThread"], - }, - preview: { - open: () => { - throw new Error("Not implemented in browser test."); - }, - navigate: () => { - throw new Error("Not implemented in browser test."); - }, - refresh: () => { - throw new Error("Not implemented in browser test."); - }, - close: () => { - throw new Error("Not implemented in browser test."); - }, - list: () => Promise.resolve({ sessions: [] }), - reportStatus: () => { - throw new Error("Not implemented in browser test."); - }, - automation: { - connect: () => () => undefined, - respond: () => Promise.resolve(), - reportOwner: () => Promise.resolve(), - clearOwner: () => Promise.resolve(), - }, - onEvent: () => () => undefined, - subscribePorts: () => () => undefined, - } as EnvironmentApi["preview"], - }; -} - -function createUserMessage(options: { - id: MessageId; - text: string; - offsetSeconds: number; - attachments?: Array<{ - type: "image"; - id: string; - name: string; - mimeType: string; - sizeBytes: number; - }>; -}) { - return { - id: options.id, - role: "user" as const, - text: options.text, - ...(options.attachments ? { attachments: options.attachments } : {}), - turnId: null, - streaming: false, - createdAt: isoAt(options.offsetSeconds), - updatedAt: isoAt(options.offsetSeconds + 1), - }; -} - -function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { - return { - id: options.id, - role: "assistant" as const, - text: options.text, - turnId: null, - streaming: false, - createdAt: isoAt(options.offsetSeconds), - updatedAt: isoAt(options.offsetSeconds + 1), - }; -} - -function createTerminalContext(input: { - id: string; - terminalLabel: string; - lineStart: number; - lineEnd: number; - text: string; -}): TerminalContextDraft { - return { - id: input.id, - threadId: THREAD_ID, - terminalId: `terminal-${input.id}`, - terminalLabel: input.terminalLabel, - lineStart: input.lineStart, - lineEnd: input.lineEnd, - text: input.text, - createdAt: NOW_ISO, - }; -} - -function createSnapshotForTargetUser(options: { - targetMessageId: MessageId; - targetText: string; - targetAttachmentCount?: number; - sessionStatus?: OrchestrationSessionStatus; -}): OrchestrationReadModel { - const messages: Array = []; - - for (let index = 0; index < 22; index += 1) { - const isTarget = index === 3; - const userId = `msg-user-${index}` as MessageId; - const assistantId = `msg-assistant-${index}` as MessageId; - const attachments = - isTarget && (options.targetAttachmentCount ?? 0) > 0 - ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({ - type: "image" as const, - id: `attachment-${attachmentIndex + 1}`, - name: `attachment-${attachmentIndex + 1}.png`, - mimeType: "image/png", - sizeBytes: 128, - })) - : undefined; - - messages.push( - createUserMessage({ - id: isTarget ? options.targetMessageId : userId, - text: isTarget ? options.targetText : `filler user message ${index}`, - offsetSeconds: messages.length * 3, - ...(attachments ? { attachments } : {}), - }), - ); - messages.push( - createAssistantMessage({ - id: assistantId, - text: `assistant filler ${index}`, - offsetSeconds: messages.length * 3, - }), - ); - } - - return { - snapshotSequence: 1, - projects: [ - { - id: PROJECT_ID, - title: "Project", - workspaceRoot: "/repo/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - scripts: [], - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - }, - ], - threads: [ - { - id: THREAD_ID, - projectId: PROJECT_ID, - title: THREAD_TITLE, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "main", - worktreePath: null, - latestTurn: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - archivedAt: null, - deletedAt: null, - messages, - activities: [], - proposedPlans: [], - checkpoints: [], - goal: null, - session: { - threadId: THREAD_ID, - status: options.sessionStatus ?? "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW_ISO, - }, - }, - ], - updatedAt: NOW_ISO, - }; -} - -function buildFixture(snapshot: OrchestrationReadModel): TestFixture { - return { - snapshot, - serverConfig: createBaseServerConfig(), - welcome: { - environment: { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/repo/project", - projectName: "Project", - bootstrapProjectId: PROJECT_ID, - bootstrapThreadId: THREAD_ID, - }, - terminalMetadataEvents: [], - }; -} - -function addThreadToSnapshot( - snapshot: OrchestrationReadModel, - threadId: ThreadId, -): OrchestrationReadModel { - return { - ...snapshot, - snapshotSequence: snapshot.snapshotSequence + 1, - threads: [ - ...snapshot.threads, - { - id: threadId, - projectId: PROJECT_ID, - title: "New thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "main", - worktreePath: null, - latestTurn: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - archivedAt: null, - deletedAt: null, - messages: [], - activities: [], - proposedPlans: [], - checkpoints: [], - goal: null, - session: { - threadId, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW_ISO, - }, - }, - ], - }; -} - -function toShellThread(thread: OrchestrationReadModel["threads"][number]) { - return { - id: thread.id, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestTurn: thread.latestTurn, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - archivedAt: thread.archivedAt, - session: thread.session, - latestUserMessageAt: - thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - goal: thread.goal, - }; -} - -function toShellSnapshot(snapshot: OrchestrationReadModel) { - return { - snapshotSequence: snapshot.snapshotSequence, - projects: snapshot.projects.map((project) => ({ - id: project.id, - title: project.title, - workspaceRoot: project.workspaceRoot, - repositoryIdentity: project.repositoryIdentity ?? null, - defaultModelSelection: project.defaultModelSelection, - scripts: project.scripts, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - })), - threads: snapshot.threads.map(toShellThread), - updatedAt: snapshot.updatedAt, - }; -} - -function updateThreadSessionInSnapshot( - snapshot: OrchestrationReadModel, - threadId: ThreadId, - session: OrchestrationReadModel["threads"][number]["session"], -): OrchestrationReadModel { - return { - ...snapshot, - snapshotSequence: snapshot.snapshotSequence + 1, - threads: snapshot.threads.map((thread) => - thread.id === threadId - ? { - ...thread, - session, - updatedAt: NOW_ISO, - } - : thread, - ), - }; -} - -function sendShellThreadUpsert( - threadId: ThreadId, - options?: { - readonly session?: OrchestrationReadModel["threads"][number]["session"]; - }, -): void { - const thread = fixture.snapshot.threads.find((entry) => entry.id === threadId); - if (!thread) { - throw new Error(`Expected thread ${threadId} in snapshot.`); - } - - const shellThread = - options?.session !== undefined - ? toShellThread({ ...thread, session: options.session }) - : toShellThread(thread); - rpcHarness.emitStreamValue(ORCHESTRATION_WS_METHODS.subscribeShell, { - kind: "thread-upserted", - sequence: fixture.snapshot.snapshotSequence, - thread: shellThread, - }); -} - -async function waitForWsClient(): Promise { - await vi.waitFor( - () => { - expect( - wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell), - ).toBe(true); - expect( - wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), - ).toBe(true); - expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( - true, - ); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -function threadRefFor(threadId: ThreadId) { - return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); -} - -function threadKeyFor(threadId: ThreadId): string { - return scopedThreadKey(threadRefFor(threadId)); -} - -function composerDraftFor(target: string) { - const { draftsByThreadKey } = useComposerDraftStore.getState(); - return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)]; -} - -function draftIdFromPath(pathname: string) { - const segments = pathname.split("/"); - const draftId = segments[segments.length - 1]; - if (!draftId) { - throw new Error(`Expected thread path, received "${pathname}".`); - } - return DraftId.make(draftId); -} - -function draftThreadIdFor(draftId: ReturnType): ThreadId { - const draftSession = useComposerDraftStore.getState().getDraftSession(draftId); - if (!draftSession) { - throw new Error(`Expected draft session for "${draftId}".`); - } - return draftSession.threadId; -} - -function serverThreadPath(threadId: ThreadId): string { - return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`; -} - -async function waitForAppBootstrap(): Promise { - await vi.waitFor( - () => { - expect(getServerConfig()).not.toBeNull(); - expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise { - await waitForWsClient(); - fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); - fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, null); - sendShellThreadUpsert(threadId, { session: null }); -} - -async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise { - fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, { - threadId, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: `turn-${threadId}` as TurnId, - lastError: null, - updatedAt: NOW_ISO, - }); - sendShellThreadUpsert(threadId); -} - -async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { - await materializePromotedDraftThreadViaDomainEvent(threadId); - await startPromotedServerThreadViaDomainEvent(threadId); - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe( - undefined, - ); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -function createDraftOnlySnapshot(): OrchestrationReadModel { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-target" as MessageId, - targetText: "draft thread", - }); - return { - ...snapshot, - threads: [], - }; -} - -function createProjectlessSnapshot(): OrchestrationReadModel { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-projectless-target" as MessageId, - targetText: "projectless", - }); - return { - ...snapshot, - projects: [], - threads: [], - }; -} - -function withProjectScripts( - snapshot: OrchestrationReadModel, - scripts: OrchestrationReadModel["projects"][number]["scripts"], -): OrchestrationReadModel { - return { - ...snapshot, - projects: snapshot.projects.map((project) => - project.id === PROJECT_ID ? { ...project, scripts: Array.from(scripts) } : project, - ), - }; -} - -function setDraftThreadWithoutWorktree(): void { - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); -} - -function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-plan-target" as MessageId, - targetText: "plan thread", - }); - const planMarkdown = [ - "# Ship plan mode follow-up", - "", - "- Step 1: capture the thread-open trace", - "- Step 2: identify the main-thread bottleneck", - "- Step 3: keep collapsed cards cheap", - "- Step 4: render the full markdown only on demand", - "- Step 5: preserve export and save actions", - "- Step 6: add regression coverage", - "- Step 7: verify route transitions stay responsive", - "- Step 8: confirm no server-side work changed", - "- Step 9: confirm short plans still render normally", - "- Step 10: confirm long plans stay collapsed by default", - "- Step 11: confirm preview text is still useful", - "- Step 12: confirm plan follow-up flow still works", - "- Step 13: confirm timeline virtualization still behaves", - "- Step 14: confirm theme styling still looks correct", - "- Step 15: confirm save dialog behavior is unchanged", - "- Step 16: confirm download behavior is unchanged", - "- Step 17: confirm code fences do not parse until expand", - "- Step 18: confirm preview truncation ends cleanly", - "- Step 19: confirm markdown links still open in editor after expand", - "- Step 20: confirm deep hidden detail only appears after expand", - "", - "```ts", - "export const hiddenPlanImplementationDetail = 'deep hidden detail only after expand';", - "```", - ].join("\n"); - - return { - ...snapshot, - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - proposedPlans: [ - { - id: "plan-browser-test", - turnId: null, - planMarkdown, - implementedAt: null, - implementationThreadId: null, - createdAt: isoAt(1_000), - updatedAt: isoAt(1_001), - }, - ], - updatedAt: isoAt(1_001), - }) - : thread, - ), - }; -} - -function createSnapshotWithSecondaryProject(options?: { - includeSecondaryThread?: boolean; - includeArchivedSecondaryThread?: boolean; -}): OrchestrationReadModel { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-secondary-project-target" as MessageId, - targetText: "secondary project", - }); - const includeSecondaryThread = options?.includeSecondaryThread ?? true; - const includeArchivedSecondaryThread = options?.includeArchivedSecondaryThread ?? true; - const secondaryThreads: OrchestrationReadModel["threads"] = includeSecondaryThread - ? [ - { - id: "thread-secondary-project" as ThreadId, - projectId: SECOND_PROJECT_ID, - title: "Release checklist", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "release/docs-portal", - worktreePath: null, - latestTurn: null, - createdAt: isoAt(30), - updatedAt: isoAt(31), - deletedAt: null, - messages: [], - activities: [], - proposedPlans: [], - checkpoints: [], - goal: null, - session: { - threadId: "thread-secondary-project" as ThreadId, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: isoAt(31), - }, - archivedAt: null, - }, - ] - : []; - const archivedSecondaryThreads: OrchestrationReadModel["threads"] = includeArchivedSecondaryThread - ? [ - { - id: ARCHIVED_SECONDARY_THREAD_ID, - projectId: SECOND_PROJECT_ID, - title: "Archived Docs Notes", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "release/docs-archive", - worktreePath: null, - latestTurn: null, - createdAt: isoAt(24), - updatedAt: isoAt(25), - deletedAt: null, - messages: [], - activities: [], - proposedPlans: [], - checkpoints: [], - goal: null, - session: { - threadId: ARCHIVED_SECONDARY_THREAD_ID, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: isoAt(25), - }, - archivedAt: isoAt(26), - }, - ] - : []; - - return { - ...snapshot, - projects: [ - ...snapshot.projects, - { - id: SECOND_PROJECT_ID, - title: "Docs Portal", - workspaceRoot: "/repo/clients/docs-portal", - defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, - scripts: [], - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - }, - ], - threads: [...snapshot.threads, ...secondaryThreads, ...archivedSecondaryThreads], - }; -} - -function createSnapshotWithPendingUserInput(): OrchestrationReadModel { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-pending-input-target" as MessageId, - targetText: "question thread", - }); - - return { - ...snapshot, - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - interactionMode: "plan", - activities: [ - { - id: EventId.make("activity-user-input-requested"), - tone: "info", - kind: "user-input.requested", - summary: "User input requested", - payload: { - requestId: "req-browser-user-input", - questions: [ - { - id: "scope", - header: "Scope", - question: "What should this change cover?", - options: [ - { - label: "Tight", - description: "Touch only the footer layout logic.", - }, - { - label: "Broad", - description: "Also adjust the related composer controls.", - }, - ], - }, - { - id: "risk", - header: "Risk", - question: "How aggressive should the imaginary plan be?", - options: [ - { - label: "Conservative", - description: "Favor reliability and low-risk changes.", - }, - { - label: "Balanced", - description: "Mix quick wins with one structural improvement.", - }, - ], - }, - ], - }, - turnId: null, - sequence: 1, - createdAt: isoAt(1_000), - }, - ], - updatedAt: isoAt(1_000), - }) - : thread, - ), - }; -} - -function createSnapshotWithPlanFollowUpPrompt(options?: { - modelSelection?: { instanceId: ProviderInstanceId; model: string }; - planMarkdown?: string; -}): OrchestrationReadModel { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-plan-follow-up-target" as MessageId, - targetText: "plan follow-up thread", - }); - const modelSelection = options?.modelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }; - const planMarkdown = - options?.planMarkdown ?? "# Follow-up plan\n\n- Keep the composer footer stable on resize."; - - return { - ...snapshot, - projects: snapshot.projects.map((project) => - project.id === PROJECT_ID ? { ...project, defaultModelSelection: modelSelection } : project, - ), - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - modelSelection, - interactionMode: "plan", - latestTurn: { - turnId: "turn-plan-follow-up" as TurnId, - state: "completed", - requestedAt: isoAt(1_000), - startedAt: isoAt(1_001), - completedAt: isoAt(1_010), - assistantMessageId: null, - }, - proposedPlans: [ - { - id: "plan-follow-up-browser-test", - turnId: "turn-plan-follow-up" as TurnId, - planMarkdown, - implementedAt: null, - implementationThreadId: null, - createdAt: isoAt(1_002), - updatedAt: isoAt(1_003), - }, - ], - session: { - ...thread.session, - status: "ready", - updatedAt: isoAt(1_010), - }, - updatedAt: isoAt(1_010), - }) - : thread, - ), - }; -} - -function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { - const customResult = customWsRpcResolver?.(body); - if (customResult !== undefined) { - return customResult; - } - const tag = body._tag; - if (tag === WS_METHODS.serverGetConfig) { - return encodeServerConfig(fixture.serverConfig); - } - if (tag === WS_METHODS.serverDiscoverSourceControl) { - return { - versionControlSystems: [], - sourceControlProviders: [ - { - kind: "github", - label: "GitHub", - executable: "gh", - status: "available", - version: Option.some("gh version 2.0.0"), - installHint: "Install GitHub CLI.", - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("t3-oss"), - host: Option.some("github.com"), - detail: Option.none(), - }, - }, - { - kind: "gitlab", - label: "GitLab", - executable: "glab", - status: "available", - version: Option.some("glab version 1.0.0"), - installHint: "Install GitLab CLI.", - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("t3-oss"), - host: Option.some("gitlab.com"), - detail: Option.none(), - }, - }, - { - kind: "bitbucket", - label: "Bitbucket", - executable: "Bitbucket REST API", - status: "available", - version: Option.none(), - installHint: "Set Bitbucket API token environment variables.", - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("t3-oss"), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }, - }, - { - kind: "azure-devops", - label: "Azure DevOps", - executable: "az", - status: "available", - version: Option.some("azure-cli 2.0.0"), - installHint: "Install Azure CLI.", - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("t3-oss"), - host: Option.some("dev.azure.com"), - detail: Option.none(), - }, - }, - ], - }; - } - if (tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, - refs: [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: null, - }, - ], - }; - } - if (tag === WS_METHODS.projectsSearchEntries) { - return { - entries: [], - truncated: false, - }; - } - if (tag === WS_METHODS.shellOpenInEditor) { - return null; - } - if (tag === WS_METHODS.terminalOpen) { - return { - threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, - terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", - cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", - worktreePath: - typeof body.worktreePath === "string" - ? body.worktreePath - : body.worktreePath === null - ? null - : null, - status: "running", - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "Terminal 1", - updatedAt: NOW_ISO, - }; - } - return {}; -} - -const worker = setupWorker( - wsLink.addEventListener("connection", ({ client }) => { - void rpcHarness.connect(client); - client.addEventListener("message", (event) => { - const rawData = event.data; - if (typeof rawData !== "string") return; - void rpcHarness.onMessage(rawData); - }); - }), - ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/api/assets/test/:assetName", () => - HttpResponse.text(ATTACHMENT_SVG, { - headers: { - "Content-Type": "image/svg+xml", - }, - }), - ), -); - -async function nextFrame(): Promise { - await new Promise((resolve) => { - window.requestAnimationFrame(() => resolve()); - }); -} - -async function waitForLayout(): Promise { - await nextFrame(); - await nextFrame(); - await nextFrame(); -} - -async function setViewport(viewport: ViewportSpec): Promise { - await page.viewport(viewport.width, viewport.height); - await waitForLayout(); -} - -async function waitForProductionStyles(): Promise { - await vi.waitFor( - () => { - expect( - getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), - ).not.toBe(""); - expect(getComputedStyle(document.body).marginTop).toBe("0px"); - }, - { - timeout: 4_000, - interval: 16, - }, - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { - timeout: 8_000, - interval: 16, - }, - ); - if (!element) { - throw new Error(errorMessage); - } - return element; -} - -async function waitForURL( - router: ReturnType, - predicate: (pathname: string) => boolean, - errorMessage: string, -): Promise { - let pathname = ""; - await vi.waitFor( - () => { - pathname = router.state.location.pathname; - expect(predicate(pathname), errorMessage).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - return pathname; -} - -async function waitForComposerEditor(): Promise { - return waitForElement( - () => document.querySelector('[contenteditable="true"]'), - "Unable to find composer editor.", - ); -} - -async function pressComposerKey(key: string): Promise { - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - const keydownEvent = new KeyboardEvent("keydown", { - key, - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(keydownEvent); - if (keydownEvent.defaultPrevented) { - await waitForLayout(); - return; - } - - const beforeInputEvent = new InputEvent("beforeinput", { - data: key, - inputType: "insertText", - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(beforeInputEvent); - if (beforeInputEvent.defaultPrevented) { - await waitForLayout(); - return; - } - - if ( - typeof document.execCommand === "function" && - document.execCommand("insertText", false, key) - ) { - await waitForLayout(); - return; - } - - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - throw new Error("Unable to resolve composer selection for text input."); - } - const range = selection.getRangeAt(0); - range.deleteContents(); - const textNode = document.createTextNode(key); - range.insertNode(textNode); - range.setStartAfter(textNode); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - composerEditor.dispatchEvent( - new InputEvent("input", { - data: key, - inputType: "insertText", - bubbles: true, - }), - ); - await waitForLayout(); -} - -async function pressComposerUndo(): Promise { - const composerEditor = await waitForComposerEditor(); - const useMetaForMod = isMacPlatform(navigator.platform); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "z", - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); -} - -async function waitForComposerText(expectedText: string): Promise { - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe( - expectedText, - ); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function setComposerSelectionByTextOffsets(options: { - start: number; - end: number; - direction?: "forward" | "backward"; -}): Promise { - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - const resolvePoint = (targetOffset: number) => { - const traversedRef = { value: 0 }; - - const visitNode = (node: Node): { node: Node; offset: number } | null => { - if (node.nodeType === Node.TEXT_NODE) { - const textLength = node.textContent?.length ?? 0; - if (targetOffset <= traversedRef.value + textLength) { - return { - node, - offset: Math.max(0, Math.min(targetOffset - traversedRef.value, textLength)), - }; - } - traversedRef.value += textLength; - return null; - } - - if (node instanceof HTMLBRElement) { - const parent = node.parentNode; - if (!parent) { - return null; - } - const siblingIndex = Array.prototype.indexOf.call(parent.childNodes, node); - if (targetOffset <= traversedRef.value) { - return { node: parent, offset: siblingIndex }; - } - if (targetOffset <= traversedRef.value + 1) { - return { node: parent, offset: siblingIndex + 1 }; - } - traversedRef.value += 1; - return null; - } - - if (node instanceof Element || node instanceof DocumentFragment) { - for (const child of node.childNodes) { - const point = visitNode(child); - if (point) { - return point; - } - } - } - - return null; - }; - - return ( - visitNode(composerEditor) ?? { - node: composerEditor, - offset: composerEditor.childNodes.length, - } - ); - }; - - const startPoint = resolvePoint(options.start); - const endPoint = resolvePoint(options.end); - const selection = window.getSelection(); - if (!selection) { - throw new Error("Unable to resolve window selection."); - } - selection.removeAllRanges(); - - if (options.direction === "backward" && "setBaseAndExtent" in selection) { - selection.setBaseAndExtent(endPoint.node, endPoint.offset, startPoint.node, startPoint.offset); - await waitForLayout(); - return; - } - - const range = document.createRange(); - range.setStart(startPoint.node, startPoint.offset); - range.setEnd(endPoint.node, endPoint.offset); - selection.addRange(range); - await waitForLayout(); -} - -async function selectAllComposerContent(): Promise { - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - const selection = window.getSelection(); - if (!selection) { - throw new Error("Unable to resolve window selection."); - } - selection.removeAllRanges(); - const range = document.createRange(); - range.selectNodeContents(composerEditor); - selection.addRange(range); - await waitForLayout(); -} - -async function waitForComposerMenuItem(itemId: string): Promise { - return waitForElement( - () => document.querySelector(`[data-composer-item-id="${itemId}"]`), - `Unable to find composer menu item "${itemId}".`, - ); -} -async function waitForSendButton(): Promise { - return waitForElement( - () => document.querySelector('button[aria-label="Send message"]'), - "Unable to find send button.", - ); -} - -function findComposerProviderModelPicker(): HTMLButtonElement | null { - return document.querySelector('[data-chat-provider-model-picker="true"]'); -} - -function findButtonByText(text: string): HTMLButtonElement | null { - return (Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === text, - ) ?? null) as HTMLButtonElement | null; -} - -async function waitForButtonByText(text: string): Promise { - return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`); -} - -function findButtonContainingText(text: string): HTMLElement | null { - return ( - Array.from(document.querySelectorAll('button, [role="button"]')).find((button) => - button.textContent?.includes(text), - ) ?? null - ); -} - -async function waitForButtonContainingText(text: string): Promise { - return waitForElement( - () => findButtonContainingText(text), - `Unable to find button containing "${text}".`, - ); -} - -async function waitForSelectItemContainingText(text: string): Promise { - return waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="select-item"]')).find((item) => - item.textContent?.includes(text), - ) ?? null, - `Unable to find select item containing "${text}".`, - ); -} - -async function expectComposerActionsContained(): Promise { - const footer = await waitForElement( - () => document.querySelector('[data-chat-composer-footer="true"]'), - "Unable to find composer footer.", - ); - const actions = await waitForElement( - () => document.querySelector('[data-chat-composer-actions="right"]'), - "Unable to find composer actions container.", - ); - - await vi.waitFor( - () => { - const footerRect = footer.getBoundingClientRect(); - const actionButtons = Array.from(actions.querySelectorAll("button")); - expect(actionButtons.length).toBeGreaterThanOrEqual(1); - - const buttonRects = actionButtons.map((button) => button.getBoundingClientRect()); - const firstTop = buttonRects[0]?.top ?? 0; - - for (const rect of buttonRects) { - expect(rect.right).toBeLessThanOrEqual(footerRect.right + 0.5); - expect(rect.bottom).toBeLessThanOrEqual(footerRect.bottom + 0.5); - expect(Math.abs(rect.top - firstTop)).toBeLessThanOrEqual(1.5); - } - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForInteractionModeButton( - expectedLabel: "Build" | "Plan", -): Promise { - return waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === expectedLabel, - ) as HTMLButtonElement | null, - `Unable to find ${expectedLabel} interaction mode button.`, - ); -} - -async function waitForServerConfigToApply(): Promise { - await vi.waitFor( - () => { - expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( - true, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - await waitForLayout(); -} - -function dispatchChatNewShortcut(): void { - const useMetaForMod = isMacPlatform(navigator.platform); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "o", - shiftKey: true, - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); -} - -function dispatchConfiguredDiffToggleShortcut(): void { - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "g", - shiftKey: true, - altKey: true, - bubbles: true, - cancelable: true, - }), - ); -} - -function releaseModShortcut(key?: string): void { - window.dispatchEvent( - new KeyboardEvent("keyup", { - key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"), - metaKey: false, - ctrlKey: false, - bubbles: true, - cancelable: true, - }), - ); -} - -async function triggerChatNewShortcutUntilPath( - router: ReturnType, - predicate: (pathname: string) => boolean, - errorMessage: string, -): Promise { - let pathname = router.state.location.pathname; - const deadline = Date.now() + 8_000; - while (Date.now() < deadline) { - dispatchChatNewShortcut(); - await waitForLayout(); - pathname = router.state.location.pathname; - if (predicate(pathname)) { - return pathname; - } - } - throw new Error(`${errorMessage} Last path: ${pathname}`); -} - -async function openCommandPaletteFromTrigger(): Promise { - const trigger = page.getByTestId("command-palette-trigger"); - await expect.element(trigger).toBeInTheDocument(); - await trigger.click(); - await waitForElement( - () => document.querySelector('[data-testid="command-palette"]'), - "Command palette should have opened from the sidebar trigger.", - ); -} - -async function waitForNewThreadShortcutLabel(): Promise { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - await newThreadButton.hover(); - const shortcutLabel = isMacPlatform(navigator.platform) - ? "New thread (⇧⌘O)" - : "New thread (Ctrl+Shift+O)"; - await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument(); -} - -async function waitForCommandPaletteShortcutLabel(): Promise { - await waitForElement( - () => document.querySelector('[data-testid="command-palette-trigger"] kbd'), - "Command palette shortcut label did not render.", - ); -} - -async function waitForCommandPaletteInput(placeholder: string): Promise { - return waitForElement( - () => document.querySelector(`input[placeholder="${placeholder}"]`) as HTMLInputElement | null, - `Command palette input with placeholder "${placeholder}" did not render.`, - ); -} - -function getCommandPaletteLegendEntries(): string[] { - const footer = document.querySelector('[data-slot="command-footer"]'); - if (!footer) { - return []; - } - - return Array.from(footer.querySelectorAll('[data-slot="kbd-group"]')) - .map((group) => - Array.from(group.children) - .map((child) => child.textContent?.trim() ?? "") - .filter((value) => value.length > 0) - .join(" "), - ) - .filter((value) => value.length > 0); -} - -async function dispatchInputKey( - input: HTMLInputElement, - init: Pick, -): Promise { - input.focus(); - input.dispatchEvent( - new KeyboardEvent("keydown", { - bubbles: true, - cancelable: true, - ...init, - }), - ); - await waitForLayout(); -} - -async function mountChatView(options: { - viewport: ViewportSpec; - snapshot: OrchestrationReadModel; - configureFixture?: (fixture: TestFixture) => void; - resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; - initialPath?: string; -}): Promise { - fixture = buildFixture(options.snapshot); - options.configureFixture?.(fixture); - customWsRpcResolver = options.resolveRpc ?? null; - await setViewport(options.viewport); - await waitForProductionStyles(); - - const host = document.createElement("div"); - host.style.position = "fixed"; - host.style.top = "0"; - host.style.left = "0"; - host.style.width = "100vw"; - host.style.height = "100vh"; - host.style.display = "grid"; - host.style.overflow = "hidden"; - document.body.append(host); - - const router = getRouter( - createMemoryHistory({ - initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], - }), - ROOT_BASE_PATH, - ); - - const screen = await render( - - - , - { - container: host, - }, - ); - - await waitForWsClient(); - await waitForAppBootstrap(); - await waitForLayout(); - - const cleanup = async () => { - customWsRpcResolver = null; - await screen.unmount(); - host.remove(); - await waitForLayout(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - setViewport: async (viewport: ViewportSpec) => { - await setViewport(viewport); - await waitForProductionStyles(); - }, - setContainerSize: async (viewport) => { - host.style.width = `${viewport.width}px`; - host.style.height = `${viewport.height}px`; - await waitForLayout(); - }, - router, - }; -} - -describe("ChatView timeline estimator parity (full app)", () => { - beforeAll(async () => { - fixture = buildFixture( - createSnapshotForTargetUser({ - targetMessageId: "msg-user-bootstrap" as MessageId, - targetText: "bootstrap", - }), - ); - await worker.start({ - onUnhandledRequest: "bypass", - quiet: true, - serviceWorker: { - url: "/mockServiceWorker.js", - }, - }); - }); - - afterAll(async () => { - await rpcHarness.disconnect(); - await worker.stop(); - }); - - beforeEach(async () => { - await rpcHarness.reset({ - resolveUnary: resolveWsRpc, - getInitialStreamValues: (request) => { - if (request._tag === WS_METHODS.subscribeServerLifecycle) { - return [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: fixture.welcome, - }, - ]; - } - if (request._tag === WS_METHODS.subscribeServerConfig) { - return [ - { - version: 1, - type: "snapshot", - config: encodeServerConfig(fixture.serverConfig), - }, - ]; - } - if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { - return [ - { - kind: "snapshot", - snapshot: toShellSnapshot(fixture.snapshot), - }, - ]; - } - if (request._tag === ORCHESTRATION_WS_METHODS.subscribeThread) { - const thread = fixture.snapshot.threads.find((entry) => entry.id === request.threadId); - return thread - ? [ - { - kind: "snapshot", - snapshot: { - snapshotSequence: fixture.snapshot.snapshotSequence, - thread, - }, - }, - ] - : []; - } - if (request._tag === WS_METHODS.subscribeTerminalMetadata) { - return fixture.terminalMetadataEvents; - } - return []; - }, - }); - await __resetLocalApiForTests(); - await setViewport(DEFAULT_VIEWPORT); - localStorage.clear(); - document.body.innerHTML = ""; - wsRequests.length = 0; - customWsRpcResolver = null; - __resetEnvironmentApiOverridesForTests(); - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); - Reflect.deleteProperty(window, "desktopBridge"); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - stickyActiveProvider: null, - }); - useCommandPaletteStore.setState({ - open: false, - openIntent: null, - }); - useStore.setState({ - activeEnvironmentId: null, - environmentStateById: {}, - }); - useUiStateStore.setState({ - projectExpandedById: {}, - projectOrder: [], - threadLastVisitedAtById: {}, - }); - useTerminalUiStateStore.persist.clearStorage(); - useTerminalUiStateStore.setState({ - terminalUiStateByThreadKey: {}, - }); - useRightPanelStore.persist.clearStorage(); - useRightPanelStore.setState({ byThreadKey: {} }); - }); - - afterEach(() => { - customWsRpcResolver = null; - document.body.innerHTML = ""; - }); - - it("renders locked single-environment mobile run context as a static workspace label", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-mobile-locked-workspace" as MessageId, - targetText: "locked mobile workspace", - }), - }); - - try { - await waitForElement( - () => - Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "Local checkout", - ) ?? null, - "Unable to find static mobile workspace label.", - ); - - expect(findButtonByText("Local checkout")).toBeNull(); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps dismiss-only composer banners aligned on mobile", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-mobile-version-banner" as MessageId, - targetText: "mobile version banner", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - environment: { - ...nextFixture.serverConfig.environment, - serverVersion: "9.9.9", - }, - }; - }, - }); - - try { - const banner = await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="alert"]')).find( - (element) => element.textContent?.includes("Client and server versions differ"), - ) ?? null, - "Unable to find version mismatch banner.", - ); - const title = banner.querySelector('[data-slot="alert-title"]'); - const description = banner.querySelector('[data-slot="alert-description"]'); - const dismissButton = banner.querySelector( - 'button[aria-label="Dismiss version mismatch warning"]', - ); - - expect(title).toBeTruthy(); - expect(description).toBeTruthy(); - expect(dismissButton).toBeTruthy(); - expect(dismissButton!.getBoundingClientRect().top).toBeLessThan( - description!.getBoundingClientRect().top, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("re-expands the bootstrap project using its logical key", async () => { - useUiStateStore.setState({ - projectExpandedById: { - [PROJECT_LOGICAL_KEY]: false, - }, - projectOrder: [PROJECT_LOGICAL_KEY], - threadLastVisitedAtById: {}, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-bootstrap-project-expand" as MessageId, - targetText: "bootstrap project expand", - }), - }); - - try { - await vi.waitFor( - () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows an explicit empty state for projects without threads in the sidebar", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - }); - - try { - await expect.element(page.getByText("No threads yet")).toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("opens the project cwd for draft threads without a worktree path", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - await vi.waitFor(() => { - expect(openButton.disabled).toBe(false); - }); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscode", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("does not leak a server worktree path into drawer runtime env when launch context clears it", async () => { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-launch-context-target" as MessageId, - targetText: "launch context worktree override", - }); - const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); - if (targetThread) { - Object.assign(targetThread, { - branch: "feature/branch", - worktreePath: "/repo/worktrees/feature-branch", - }); - } - - useTerminalUiStateStore.setState({ - terminalUiStateByThreadKey: { - [THREAD_KEY]: { - terminalOpen: true, - terminalHeight: 280, - terminalIds: ["default"], - activeTerminalId: "default", - terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], - activeTerminalGroupId: "group-default", - }, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot, - configureFixture: (nextFixture) => { - nextFixture.terminalMetadataEvents = [ - { - type: "upsert", - terminal: { - threadId: THREAD_ID, - terminalId: DEFAULT_TERMINAL_ID, - cwd: "/repo/project", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - hasRunningSubprocess: false, - label: "Terminal 1", - updatedAt: isoAt(0), - }, - }, - ]; - }, - }); - - try { - await vi.waitFor( - () => { - const attachRequest = wsRequests - .toReversed() - .find((request) => request._tag === WS_METHODS.terminalAttach) as - | { - _tag: string; - cwd?: string; - worktreePath?: string | null; - env?: Record; - } - | undefined; - expect(attachRequest).toMatchObject({ - _tag: WS_METHODS.terminalAttach, - cwd: "/repo/project", - worktreePath: null, - }); - expect(attachRequest?.env).toBeUndefined(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("attaches the default terminal when opening an empty terminal drawer", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-open-empty-terminal-drawer" as MessageId, - targetText: "open empty terminal drawer", - }), - }); - - try { - const terminalToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", - ); - terminalToggle.click(); - - await vi.waitFor( - () => { - const attachRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalAttach, - ); - expect(attachRequest).toMatchObject({ - _tag: WS_METHODS.terminalAttach, - threadId: THREAD_ID, - terminalId: DEFAULT_TERMINAL_ID, - cwd: "/repo/project", - }); - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) - .isOpen, - ).toBe(false); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the compact chat header on one row", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-compact-header" as MessageId, - targetText: "keep the compact header aligned", - }), - }); - - try { - const chatHeader = await waitForElement( - () => document.querySelector("[data-chat-header]"), - "Unable to find chat header.", - ); - const threadTitle = await waitForElement( - () => chatHeader.querySelector("h2"), - "Unable to find thread title.", - ); - const headerActions = await waitForElement( - () => document.querySelector("[data-chat-header-actions]"), - "Unable to find chat header actions.", - ); - - const headerRect = chatHeader.getBoundingClientRect(); - const titleRect = threadTitle.getBoundingClientRect(); - const actionsRect = headerActions.getBoundingClientRect(); - const headerCenter = headerRect.top + headerRect.height / 2; - - expect(headerRect.height).toBe(52); - expect(titleRect.top).toBeGreaterThanOrEqual(headerRect.top); - expect(titleRect.bottom).toBeLessThanOrEqual(headerRect.bottom); - expect(actionsRect.top).toBeGreaterThanOrEqual(headerRect.top); - expect(actionsRect.bottom).toBeLessThanOrEqual(headerRect.bottom); - expect(Math.abs(titleRect.top + titleRect.height / 2 - headerCenter)).toBeLessThanOrEqual(1); - expect(Math.abs(actionsRect.top + actionsRect.height / 2 - headerCenter)).toBeLessThanOrEqual( - 1, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps panel toggles fixed and can maximize the right panel", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-maximize-right-panel" as MessageId, - targetText: "maximize right panel", - }), - }); - - try { - const terminalToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", - ); - const rightPanelToggle = await waitForElement( - () => document.querySelector('button[aria-label="Toggle right panel"]'), - "Unable to find right panel toggle.", - ); - const chatHeader = await waitForElement( - () => document.querySelector("[data-chat-header]"), - "Unable to find chat header.", - ); - const panelLayoutControls = await waitForElement( - () => document.querySelector("[data-panel-layout-controls]"), - "Unable to find panel layout controls.", - ); - expect(chatHeader.getBoundingClientRect().height).toBe(52); - expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); - expect(panelLayoutControls.getBoundingClientRect().top).toBe( - chatHeader.getBoundingClientRect().top, - ); - expect( - window.getComputedStyle(panelLayoutControls).getPropertyValue("-webkit-app-region"), - ).toBe("no-drag"); - expect(chatHeader.classList.contains("drag-region")).toBe(false); - expect(chatHeader.contains(panelLayoutControls)).toBe(true); - expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); - const initialTerminalRect = terminalToggle.getBoundingClientRect(); - const initialRightPanelRect = rightPanelToggle.getBoundingClientRect(); - const initialControlRects = [initialTerminalRect, initialRightPanelRect]; - expect(document.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); - expect(initialControlRects.every((rect) => rect.width === 28 && rect.height === 28)).toBe( - true, - ); - expect(initialControlRects.every((rect) => rect.top === initialControlRects[0]?.top)).toBe( - true, - ); - expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4); - - document.documentElement.classList.add("wco"); - expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); - expect(panelLayoutControls.getBoundingClientRect().top).toBe( - chatHeader.getBoundingClientRect().top, - ); - expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); - document.documentElement.classList.remove("wco"); - - rightPanelToggle.click(); - - const maximizeButton = await waitForElement( - () => document.querySelector('button[aria-label="Maximize panel"]'), - "Unable to find maximize panel button.", - ); - const rightPanelTabbar = await waitForElement( - () => document.querySelector("[data-right-panel-tabbar]"), - "Unable to find right panel tab bar.", - ); - const rightPanelTabList = await waitForElement( - () => document.querySelector("[data-right-panel-tab-list]"), - "Unable to find right panel tab list.", - ); - const maximizeRect = maximizeButton.getBoundingClientRect(); - const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect(); - const openPanelLayoutControls = await waitForElement( - () => document.querySelector("[data-panel-layout-controls]"), - "Unable to find open panel layout controls.", - ); - const openTerminalToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find open panel terminal toggle.", - ); - const openRightPanelToggle = await waitForElement( - () => document.querySelector('button[aria-label="Toggle right panel"]'), - "Unable to find open panel right panel toggle.", - ); - expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); - expect(rightPanelTabbarRect.height).toBe(52); - expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top); - expect(chatHeader.contains(openPanelLayoutControls)).toBe(false); - expect( - window.getComputedStyle(rightPanelTabbar).getPropertyValue("-webkit-app-region"), - ).not.toBe("drag"); - expect(rightPanelTabList.classList.contains("drag-region")).toBe(false); - expect(window.getComputedStyle(maximizeButton).getPropertyValue("-webkit-app-region")).toBe( - "no-drag", - ); - expect( - window.getComputedStyle(openTerminalToggle).getPropertyValue("-webkit-app-region"), - ).toBe("no-drag"); - expect( - window.getComputedStyle(openRightPanelToggle).getPropertyValue("-webkit-app-region"), - ).toBe("no-drag"); - expect(maximizeRect.width).toBe(28); - expect(maximizeRect.height).toBe(28); - expect(maximizeRect.top).toBe(initialTerminalRect.top); - expect(initialTerminalRect.left - maximizeRect.right).toBe(4); - expect(openTerminalToggle.getBoundingClientRect().left).toBeCloseTo( - initialTerminalRect.left, - 1, - ); - expect(openRightPanelToggle.getBoundingClientRect().left).toBeCloseTo( - initialRightPanelRect.left, - 1, - ); - - useRightPanelStore.getState().openFile(THREAD_REF, "components.json"); - const fileTabIcon = await waitForElement( - () => - document.querySelector( - '[data-right-panel-tabbar] [data-pierre-icon][data-icon-token="json"]', - ), - "Unable to find the Pierre file icon in the file tab.", - ); - expect(fileTabIcon.closest("button")?.textContent).toContain("components.json"); - - document.documentElement.classList.add("wco"); - expect(rightPanelTabbar.getBoundingClientRect().height).toBe( - openPanelLayoutControls.getBoundingClientRect().height, - ); - expect(rightPanelTabbar.getBoundingClientRect().top).toBe( - openPanelLayoutControls.getBoundingClientRect().top, - ); - document.documentElement.classList.remove("wco"); - - maximizeButton.click(); - - await vi.waitFor(() => { - const chatColumn = document.querySelector( - '[data-chat-column-maximized-away="true"]', - ); - const panel = document.querySelector( - '[data-preview-panel-mode="inline"][data-preview-panel-maximized="true"]', - ); - expect(chatColumn?.getBoundingClientRect().width).toBe(0); - expect(panel?.getBoundingClientRect().width).toBeGreaterThan(1_000); - expect( - document.querySelector('button[aria-label="Restore panel size"]'), - ).not.toBeNull(); - expect( - document - .querySelector('button[aria-label="Toggle terminal drawer"]') - ?.getBoundingClientRect().left, - ).toBeCloseTo(initialTerminalRect.left, 1); - expect( - document - .querySelector('button[aria-label="Toggle right panel"]') - ?.getBoundingClientRect().left, - ).toBeCloseTo(initialRightPanelRect.left, 1); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("renders the plan surface in the inline right panel", async () => { - useRightPanelStore.getState().open(THREAD_REF, "plan"); - - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-inline-plan-panel" as MessageId, - targetText: "show the inline plan panel", - }), - }); - - try { - await waitForElement( - () => - Array.from(document.querySelectorAll("p")).find( - (element) => element.textContent?.trim() === "No active plan yet.", - ) ?? null, - "Unable to find inline plan panel content.", - ); - - expect( - document.querySelector("[data-right-panel-tabbar]")?.textContent, - ).toContain("Plan"); - expect(document.body.textContent).toContain("Plans will appear here when generated."); - } finally { - await mounted.cleanup(); - } - }); - - it("renders the shared panel toggles in the responsive right-panel sheet", async () => { - useRightPanelStore.getState().open(THREAD_REF, "plan"); - useRightPanelStore.getState().openTerminal(THREAD_REF, DEFAULT_TERMINAL_ID); - useRightPanelStore.getState().activateSurface(THREAD_REF, "plan"); - const baseSnapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-responsive-plan-panel-controls" as MessageId, - targetText: "show responsive plan panel controls", - }); - const snapshot: OrchestrationReadModel = { - ...baseSnapshot, - threads: baseSnapshot.threads.map((thread) => - thread.id === THREAD_ID - ? { - ...thread, - activities: [ - { - id: EventId.make("activity-responsive-panel-plan"), - tone: "info", - kind: "turn.plan.updated", - summary: "Plan updated", - payload: { - explanation: "Claude Tasks", - plan: [{ step: "Keep terminal navigation available", status: "inProgress" }], - }, - turnId: null, - sequence: 1, - createdAt: isoAt(1_000), - }, - ], - } - : thread, - ), - }; - - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot, - }); - - try { - const sheet = await waitForElement( - () => document.querySelector('[data-slot="sheet-popup"]'), - "Unable to find responsive right-panel sheet.", - ); - const controls = await waitForElement( - () => sheet.querySelector("[data-panel-layout-controls]"), - "Unable to find shared controls in the responsive right-panel sheet.", - ); - const tabbar = await waitForElement( - () => sheet.querySelector("[data-right-panel-tabbar]"), - "Unable to find responsive right-panel tabbar.", - ); - const controlButtons = Array.from(controls.querySelectorAll("button")); - const tabbarRect = tabbar.getBoundingClientRect(); - const controlsRect = controls.getBoundingClientRect(); - - expect(controlButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ - "Toggle terminal drawer", - "Toggle right panel", - ]); - expect(tabbarRect.height).toBe(52); - expect(controlsRect.height).toBe(52); - expect(controlsRect.top).toBe(tabbarRect.top); - expect(window.innerWidth - controlsRect.right).toBe(12); - for (const button of controlButtons) { - const rect = button.getBoundingClientRect(); - const buttonCenter = rect.top + rect.height / 2; - const tabbarCenter = tabbarRect.top + tabbarRect.height / 2; - expect(rect.width).toBe(32); - expect(rect.height).toBe(32); - expect(Math.abs(buttonCenter - tabbarCenter)).toBeLessThanOrEqual(1); - } - expect( - controlButtons[1]!.getBoundingClientRect().left - - controlButtons[0]!.getBoundingClientRect().right, - ).toBe(4); - expect(sheet.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); - expect(sheet.querySelector('button[aria-label="Close tasks sidebar"]')).toBeNull(); - - const terminalTab = Array.from( - sheet.querySelectorAll("[data-right-panel-tab-list] button"), - ).find((button) => button.textContent?.includes("Terminal")); - terminalTab?.click(); - - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) - .activeSurfaceId, - ).toBe(`terminal:${DEFAULT_TERMINAL_ID}`); - expect(sheet.querySelector('[data-terminal-owner="right-panel"]')).not.toBeNull(); - expect(sheet.textContent).not.toContain("Claude Tasks"); - }); - - sheet.querySelector('button[aria-label="Close Plan"]')?.click(); - - await vi.waitFor(() => { - const panelState = selectThreadRightPanelState( - useRightPanelStore.getState().byThreadKey, - THREAD_REF, - ); - expect(panelState.surfaces.some((surface) => surface.kind === "plan")).toBe(false); - expect(panelState.activeSurfaceId).toBe(`terminal:${DEFAULT_TERMINAL_ID}`); - }); - - controls.querySelector('button[aria-label="Toggle right panel"]')?.click(); - - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF).isOpen, - ).toBe(false); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("loads file previews from the active thread worktree", async () => { - const worktreePath = "/repo/worktrees/file-preview-thread"; - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-worktree-file-preview" as MessageId, - targetText: "open the worktree file preview", - }); - const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); - if (!targetThread) { - throw new Error("Missing target thread fixture."); - } - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID ? { ...thread, worktreePath } : thread, - ), - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.projectsListEntries) { - return { entries: [{ path: "src/index.ts", kind: "file" }], truncated: false }; - } - if (body._tag === WS_METHODS.projectsReadFile) { - return { - relativePath: "src/index.ts", - contents: "export const worktree = true;\n", - byteLength: 30, - truncated: false, - }; - } - return undefined; - }, - }); - - try { - useRightPanelStore.getState().open(THREAD_REF, "files"); - await waitForElement( - () => document.querySelector("[data-file-browser-panel]"), - "Unable to find the worktree file explorer.", - ); - - useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); - await waitForElement( - () => document.querySelector(".file-preview-virtualizer"), - "Unable to find the worktree file preview.", - ); - - const listRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.projectsListEntries, - ); - const readRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.projectsReadFile, - ); - expect(listRequest).toMatchObject({ cwd: worktreePath }); - expect(readRequest).toMatchObject({ cwd: worktreePath, relativePath: "src/index.ts" }); - } finally { - await mounted.cleanup(); - } - }); - - it("scrolls file tabs and preserves the workspace explorer across file previews", async () => { - const workspaceEntries = [ - { path: "src", kind: "directory" as const }, - { path: "src/index.ts", kind: "file" as const }, - { path: "src/router.ts", kind: "file" as const }, - { path: "src/store.ts", kind: "file" as const }, - { path: "src/styles.css", kind: "file" as const }, - { path: "src/large.ts", kind: "file" as const }, - { path: "e2e", kind: "directory" as const }, - { path: "e2e/test-results", kind: "directory" as const }, - { - path: "e2e/test-results/playwright-integration-results", - kind: "directory" as const, - }, - { - path: "e2e/test-results/playwright-integration-results/chromium-desktop-project", - kind: "directory" as const, - }, - { - path: "e2e/test-results/playwright-integration-results/chromium-desktop-project/.last-run.json", - kind: "file" as const, - }, - { path: "README.md", kind: "file" as const }, - { path: "AGENTS.md", kind: "file" as const }, - { path: "package.json", kind: "file" as const }, - { path: "tsconfig.json", kind: "file" as const }, - ]; - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-file-tabs-and-tree-state" as MessageId, - targetText: "keep file tabs readable and preserve tree state", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.projectsListEntries) { - return { entries: workspaceEntries, truncated: false }; - } - if (body._tag === WS_METHODS.projectsReadFile) { - const relativePath = - typeof body.relativePath === "string" ? body.relativePath : "file.ts"; - const contents = - relativePath === "src/large.ts" - ? Array.from( - { length: 5_000 }, - (_, index) => `export const line${index + 1} = ${index + 1};`, - ).join("\n") - : `// ${relativePath}\n`; - return { - relativePath, - contents, - byteLength: new TextEncoder().encode(contents).byteLength, - truncated: false, - }; - } - return undefined; - }, - }); - - try { - useRightPanelStore.getState().open(THREAD_REF, "files"); - - const explorer = await waitForElement( - () => document.querySelector("[data-file-browser-panel]"), - "Unable to find the workspace file explorer.", - ); - - for (const entry of workspaceEntries) { - if (entry.kind === "file") { - useRightPanelStore.getState().openFile(THREAD_REF, entry.path); - } - } - - const tabList = await waitForElement( - () => document.querySelector("[data-right-panel-tab-list]"), - "Unable to find the right panel tab list.", - ); - const tabViewport = await waitForElement( - () => tabList.querySelector('[data-slot="scroll-area-viewport"]'), - "Unable to find the right panel tab viewport.", - ); - - await vi.waitFor(() => { - const fileTabs = Array.from(tabList.querySelectorAll("[data-active-tab]")); - expect(fileTabs.length).toBe( - workspaceEntries.filter((entry) => entry.kind === "file").length, - ); - expect(tabViewport.scrollWidth).toBeGreaterThan(tabViewport.clientWidth); - expect(tabViewport.scrollLeft).toBeGreaterThan(0); - expect(tabList.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull(); - expect( - fileTabs.every((tab) => { - const width = tab.getBoundingClientRect().width; - return width >= 100 && width <= 176; - }), - ).toBe(true); - expect(document.querySelector("[data-file-browser-panel]")).toBe(explorer); - }); - - useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); - await vi.waitFor(() => { - expect(document.querySelector("[data-file-browser-panel]")).toBe(explorer); - }); - - useRightPanelStore - .getState() - .openFile( - THREAD_REF, - "e2e/test-results/playwright-integration-results/chromium-desktop-project/.last-run.json", - ); - await mounted.setContainerSize({ width: 800, height: WIDE_FOOTER_VIEWPORT.height }); - const breadcrumbs = await waitForElement( - () => document.querySelector("[data-file-breadcrumbs]"), - "Unable to find the responsive file breadcrumbs.", - ); - const fileSubheader = breadcrumbs.closest("[data-surface-subheader]"); - const breadcrumbViewport = await waitForElement( - () => breadcrumbs.querySelector('[data-slot="scroll-area-viewport"]'), - "Unable to find the file breadcrumb viewport.", - ); - const currentCrumb = await waitForElement( - () => - Array.from( - breadcrumbs.querySelectorAll("[data-current-file-crumb='true']"), - ).find((crumb) => crumb.textContent === ".last-run.json") ?? null, - "Unable to find the current file breadcrumb.", - ); - const explorerToggle = await waitForElement( - () => document.querySelector('button[aria-label="Hide file explorer"]'), - "Unable to find the file explorer toggle.", - ); - - await vi.waitFor(() => { - const viewportRect = breadcrumbViewport.getBoundingClientRect(); - const currentCrumbRect = currentCrumb.getBoundingClientRect(); - expect(breadcrumbViewport.scrollWidth).toBeGreaterThan(breadcrumbViewport.clientWidth); - expect(breadcrumbViewport.scrollLeft).toBeGreaterThan(0); - expect(breadcrumbs.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull(); - expect(currentCrumbRect.right).toBeLessThanOrEqual(viewportRect.right + 1); - expect(viewportRect.right).toBeLessThan(explorerToggle.getBoundingClientRect().left); - expect(explorerToggle.getAttribute("aria-pressed")).toBe("true"); - expect(explorerToggle.getBoundingClientRect().width).toBe(28); - expect(explorerToggle.getBoundingClientRect().height).toBe(28); - expect(fileSubheader?.getBoundingClientRect().height).toBe(40); - expect(window.getComputedStyle(fileSubheader!).borderTopWidth).toBe("0px"); - expect(window.getComputedStyle(fileSubheader!).borderBottomWidth).toBe("1px"); - }); - - const fileSearchButton = await waitForElement( - () => - document.querySelector('button[aria-label="Search workspace files"]'), - "Unable to find the workspace file search button.", - ); - fileSearchButton.click(); - const fileTree = await waitForElement( - () => document.querySelector("file-tree-container"), - "Unable to find the file tree host.", - ); - const fileSearchInput = await waitForElement( - () => - fileTree.shadowRoot?.querySelector("[data-file-tree-search-input]") ?? - null, - "Unable to find the file tree search input.", - ); - fileSearchInput.focus(); - const searchKeyEvent = new KeyboardEvent("keydown", { - key: "r", - bubbles: true, - cancelable: true, - composed: true, - }); - fileSearchInput.dispatchEvent(searchKeyEvent); - await waitForLayout(); - expect(searchKeyEvent.defaultPrevented).toBe(false); - expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput); - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); - - const previousCodeVirtualizer = document.querySelector( - ".file-preview-virtualizer", - ); - useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); - const codeVirtualizer = await waitForElement(() => { - const current = document.querySelector(".file-preview-virtualizer"); - return current !== previousCodeVirtualizer ? current : null; - }, "Unable to find the virtualized file preview."); - expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull(); - expect(codeVirtualizer.classList.contains("overflow-auto")).toBe(true); - await vi.waitFor( - () => { - const fileHost = codeVirtualizer.querySelector("diffs-container"); - const targetLine = fileHost?.shadowRoot?.querySelector('[data-line="4000"]'); - const targetLineNumber = fileHost?.shadowRoot?.querySelector( - '[data-column-number="4000"]', - ); - const previousLine = - fileHost?.shadowRoot?.querySelector('[data-line="3999"]'); - const previousLineNumber = fileHost?.shadowRoot?.querySelector( - '[data-column-number="3999"]', - ); - expect(codeVirtualizer.scrollTop).toBeGreaterThan(0); - expect(targetLine).not.toBeNull(); - expect(previousLine).not.toBeNull(); - expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true); - expect(targetLineNumber?.hasAttribute("data-file-link-reveal")).toBe(true); - expect(targetLine?.hasAttribute("data-selected-line")).toBe(false); - expect(targetLineNumber?.hasAttribute("data-selected-line")).toBe(false); - expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull(); - expect(window.getComputedStyle(targetLine!).backgroundColor).not.toBe( - window.getComputedStyle(previousLine!).backgroundColor, - ); - expect(window.getComputedStyle(targetLineNumber!).backgroundColor).not.toBe( - window.getComputedStyle(previousLineNumber!).backgroundColor, - ); - - const viewportRect = codeVirtualizer.getBoundingClientRect(); - const lineRect = targetLine!.getBoundingClientRect(); - expect(lineRect.top).toBeGreaterThanOrEqual(viewportRect.top); - expect(lineRect.bottom).toBeLessThanOrEqual(viewportRect.bottom); - }, - { timeout: 8_000, interval: 16 }, - ); - - const fileHost = codeVirtualizer.querySelector("diffs-container"); - const targetLineNumber = - fileHost?.shadowRoot?.querySelector('[data-column-number="4000"]') ?? null; - const previousLineNumber = - fileHost?.shadowRoot?.querySelector('[data-column-number="3999"]') ?? null; - expect(targetLineNumber).not.toBeNull(); - expect(previousLineNumber).not.toBeNull(); - - targetLineNumber!.dispatchEvent( - new PointerEvent("pointermove", { - bubbles: true, - cancelable: true, - composed: true, - pointerType: "mouse", - }), - ); - await vi.waitFor(() => { - expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull(); - }); - - previousLineNumber!.dispatchEvent( - new PointerEvent("pointermove", { - bubbles: true, - cancelable: true, - composed: true, - pointerType: "mouse", - }), - ); - await vi.waitFor(() => { - expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull(); - expect(previousLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull(); - }); - - codeVirtualizer.scrollTop = 0; - useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); - await vi.waitFor( - () => { - const fileHost = codeVirtualizer.querySelector("diffs-container"); - const targetLine = fileHost?.shadowRoot?.querySelector('[data-line="4000"]'); - expect(targetLine).not.toBeNull(); - expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true); - expect(targetLine?.hasAttribute("data-selected-line")).toBe(false); - expect(codeVirtualizer.scrollTop).toBeGreaterThan(0); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("removes persisted file tabs when a draft workspace no longer exists", async () => { - const orphanedDraftId = DraftId.make("draft-orphaned-file-panel"); - const orphanedThreadId = "thread-orphaned-file-panel" as ThreadId; - const orphanedThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, orphanedThreadId); - useComposerDraftStore.getState().setProjectDraftThreadId( - { - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: "project-deleted" as ProjectId, - }, - orphanedDraftId, - { threadId: orphanedThreadId }, - ); - useRightPanelStore.getState().openFile(orphanedThreadRef, "conductor.json"); - - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-orphaned-file-panel" as MessageId, - targetText: "orphaned persisted file panel", - }), - initialPath: `/draft/${orphanedDraftId}`, - }); - - try { - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, orphanedThreadRef), - ).toEqual({ - isOpen: false, - activeSurfaceId: null, - surfaces: [], - }); - expect(document.querySelector("[data-right-panel-tabbar]")).toBeNull(); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, - targetText: "open inline terminal panel", - }), - }); - - try { - const rightPanelToggle = await waitForElement( - () => document.querySelector('button[aria-label="Toggle right panel"]'), - "Unable to find right panel toggle.", - ); - rightPanelToggle.click(); - - await vi.waitFor(() => { - expect(document.body.textContent).toContain("Open a surface"); - }); - expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ - isOpen: true, - activeSurfaceId: null, - surfaces: [], - }); - expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); - - const emptyStateTerminalButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.includes("Start a shell in this workspace."), - ) ?? null, - "Unable to find the empty-state Terminal button.", - ); - emptyStateTerminalButton.click(); - - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) - .surfaces.filter((surface) => surface.kind === "terminal") - .map((surface) => surface.resourceId), - ).toEqual(["term-1"]); - }); - - const addSurface = await waitForElement( - () => document.querySelector('button[aria-label="Add panel surface"]'), - "Unable to find add panel surface button beside the tabs.", - ); - addSurface.click(); - const secondTerminalItem = await waitForElement( - () => - Array.from(document.querySelectorAll('[role="menuitem"]')).find( - (item) => item.textContent?.trim() === "Terminal", - ) ?? null, - "Unable to find Terminal panel menu item.", - ); - secondTerminalItem.click(); - - await vi.waitFor( - () => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) - .surfaces.filter((surface) => surface.kind === "terminal") - .map((surface) => surface.resourceId), - ).toEqual(["term-1", "term-2"]); - expect( - document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'), - ).not.toBeNull(); - expect( - wsRequests - .filter((request) => request._tag === WS_METHODS.terminalOpen) - .map((request) => ("terminalId" in request ? request.terminalId : null)), - ).toEqual(expect.arrayContaining(["term-1", "term-2"])); - const attachRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalAttach && - "terminalId" in request && - request.terminalId === "term-2", - ); - expect(attachRequest).toMatchObject({ - _tag: WS_METHODS.terminalAttach, - threadId: THREAD_ID, - terminalId: "term-2", - cwd: "/repo/project", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - const drawerToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", - ); - drawerToggle.click(); - - await vi.waitFor(() => { - expect( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY], - ).toMatchObject({ - terminalOpen: true, - terminalIds: ["term-3"], - }); - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.terminalAttach && - "terminalId" in request && - request.terminalId === "term-3", - ), - ).toBe(true); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode-insiders"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - await vi.waitFor(() => { - expect(openButton.disabled).toBe(false); - }); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscode-insiders", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("opens the project cwd with Trae when it is the only available editor", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["trae"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - await vi.waitFor(() => { - expect(openButton.disabled).toBe(false); - }); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "trae", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows Kiro in the open picker menu and opens the project cwd with it", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["kiro"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const menuButton = await waitForElement( - () => document.querySelector('button[aria-label="Copy options"]'), - "Unable to find Open picker button.", - ); - (menuButton as HTMLButtonElement).click(); - - const kiroItem = await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => - item.textContent?.includes("Kiro"), - ) ?? null, - "Unable to find Kiro menu item.", - ); - (kiroItem as HTMLElement).click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "kiro", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("filters the open picker menu and opens VSCodium from the menu", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode-insiders", "vscodium"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const menuButton = await waitForElement( - () => document.querySelector('button[aria-label="Copy options"]'), - "Unable to find Open picker button.", - ); - (menuButton as HTMLButtonElement).click(); - - await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => - item.textContent?.includes("VS Code Insiders"), - ) ?? null, - "Unable to find VS Code Insiders menu item.", - ); - - expect( - Array.from(document.querySelectorAll('[data-slot="menu-item"]')).some((item) => - item.textContent?.includes("Zed"), - ), - ).toBe(false); - - const vscodiumItem = await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => - item.textContent?.includes("VSCodium"), - ) ?? null, - "Unable to find VSCodium menu item.", - ); - (vscodiumItem as HTMLElement).click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscodium", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("falls back to the first installed editor when the stored favorite is unavailable", async () => { - localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium")); - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode-insiders"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - await vi.waitFor(() => { - expect(openButton.disabled).toBe(false); - }); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscode-insiders", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("runs project scripts from local draft threads at the project cwd", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "lint", - name: "Lint", - command: "bun run lint", - icon: "lint", - runOnWorktreeCreate: false, - }, - ]), - }); - - try { - const runButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.getAttribute("aria-label") === "Run Lint", - ) as HTMLButtonElement | null, - "Unable to find Run Lint button.", - ); - runButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: THREAD_ID, - cwd: "/repo/project", - }); - expect(openRequest?.env).toBeUndefined(); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const writeRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalWrite, - ); - expect(writeRequest).toMatchObject({ - _tag: WS_METHODS.terminalWrite, - threadId: THREAD_ID, - data: "bun run lint\r", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("runs project scripts from worktree draft threads at the worktree cwd", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "feature/draft", - worktreePath: "/repo/worktrees/feature-draft", - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "test", - name: "Test", - command: "bun run test", - icon: "test", - runOnWorktreeCreate: false, - }, - ]), - }); - - try { - const runButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.getAttribute("aria-label") === "Run Test", - ) as HTMLButtonElement | null, - "Unable to find Run Test button.", - ); - runButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: THREAD_ID, - cwd: "/repo/worktrees/feature-draft", - worktreePath: "/repo/worktrees/feature-draft", - }); - expect(openRequest?.env).toBeUndefined(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("lets the server own setup after preparing a pull request worktree thread", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ]), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitResolvePullRequest) { - return { - pullRequest: { - number: 1359, - title: "Add thread archiving and settings navigation", - url: "https://github.com/pingdotgg/t3code/pull/1359", - baseBranch: "main", - headBranch: "archive-settings-overhaul", - state: "open", - }, - }; - } - if (body._tag === WS_METHODS.gitPreparePullRequestThread) { - return { - pullRequest: { - number: 1359, - title: "Add thread archiving and settings navigation", - url: "https://github.com/pingdotgg/t3code/pull/1359", - baseBranch: "main", - headBranch: "archive-settings-overhaul", - state: "open", - }, - branch: "archive-settings-overhaul", - worktreePath: "/repo/worktrees/pr-1359", - }; - } - return undefined; - }, - }); - - try { - const branchButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "main", - ) as HTMLButtonElement | null, - "Unable to find branch selector button.", - ); - branchButton.click(); - - const branchInput = await waitForElement( - () => document.querySelector('input[placeholder="Search refs..."]'), - "Unable to find ref search input.", - ); - branchInput.focus(); - await page.getByPlaceholder("Search refs...").fill("1359"); - - const checkoutItem = await waitForElement( - () => - Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "Checkout pull request", - ) as HTMLSpanElement | null, - "Unable to find checkout pull request option.", - ); - checkoutItem.click(); - - const worktreeButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Worktree", - ) as HTMLButtonElement | null, - "Unable to find Worktree button.", - ); - worktreeButton.click(); - - await vi.waitFor( - () => { - const prepareRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.gitPreparePullRequestThread, - ); - expect(prepareRequest).toMatchObject({ - _tag: WS_METHODS.gitPreparePullRequestThread, - cwd: "/repo/project", - reference: "1359", - mode: "worktree", - threadId: THREAD_ID, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", - ), - ).toBe(false); - } finally { - await mounted.cleanup(); - } - }); - - it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { - useTerminalUiStateStore.setState({ - terminalUiStateByThreadKey: {}, - }); - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ]), - resolveRpc: (body) => { - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); - await waitForLayout(); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, - ) as - | { - _tag: string; - type?: string; - bootstrap?: { - createThread?: { projectId?: string }; - prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; - runSetupScript?: boolean; - }; - } - | undefined; - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "thread.turn.start", - bootstrap: { - createThread: { - projectId: PROJECT_ID, - }, - prepareWorktree: { - projectCwd: "/repo/project", - baseBranch: "main", - branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), - }, - runSetupScript: true, - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe( - false, - ); - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.terminalWrite && - request.threadId === THREAD_ID && - request.data === "bun install\r", - ), - ).toBe(false); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { - setDraftThreadWithoutWorktree(); - const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); - const openRouterSelection = createModelSelection(openRouterInstanceId, "openai/gpt-5.5"); - useComposerDraftStore.getState().setModelSelection(THREAD_REF, openRouterSelection); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - providers: [ - ...nextFixture.serverConfig.providers, - { - driver: ProviderDriverKind.make("claudeAgent"), - instanceId: ProviderInstanceId.make("claudeAgent"), - enabled: true, - installed: true, - version: "2.1.117", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [ - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ optionDescriptors: [] }), - }, - ], - slashCommands: [], - skills: [], - }, - { - driver: ProviderDriverKind.make("claudeAgent"), - instanceId: openRouterInstanceId, - displayName: "Claude OpenRouter", - enabled: true, - installed: true, - version: "2.1.117", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [ - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ optionDescriptors: [] }), - }, - ], - slashCommands: [], - skills: [], - }, - ], - settings: { - ...nextFixture.serverConfig.settings, - providerInstances: { - ...nextFixture.serverConfig.settings.providerInstances, - [openRouterInstanceId]: { - driver: ProviderDriverKind.make("claudeAgent"), - displayName: "Claude OpenRouter", - config: { customModels: ["openai/gpt-5.5"] }, - }, - }, - }, - }; - }, - resolveRpc: (body) => { - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "Hello there"); - await waitForLayout(); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - const turnStartRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.turn.start", - ) as - | { - modelSelection?: { instanceId?: string; model?: string }; - bootstrap?: { - createThread?: { - modelSelection?: { instanceId?: string; model?: string }; - }; - }; - } - | undefined; - - expect(turnStartRequest?.modelSelection).toMatchObject({ - instanceId: openRouterInstanceId, - model: "openai/gpt-5.5", - }); - expect(turnStartRequest?.bootstrap?.createThread?.modelSelection).toMatchObject({ - instanceId: openRouterInstanceId, - model: "openai/gpt-5.5", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => { - const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread, - ), - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, - refs: [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: null, - }, - ], - }; - } - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - (await waitForButtonByText("Current checkout")).click(); - await page.getByText("New worktree", { exact: true }).click(); - - await vi.waitFor( - () => { - expect(findButtonByText("New worktree")).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - - useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); - await waitForLayout(); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - const turnStartRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.turn.start", - ) as - | { - _tag: string; - type?: string; - bootstrap?: { - createThread?: { projectId?: string }; - prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; - runSetupScript?: boolean; - }; - } - | undefined; - - expect(turnStartRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "thread.turn.start", - bootstrap: { - prepareWorktree: { - projectCwd: "/repo/project", - baseBranch: "main", - branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), - }, - runSetupScript: true, - }, - }); - expect(turnStartRequest?.bootstrap?.createThread).toBeUndefined(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("updates the selected worktree base branch on empty server threads", async () => { - const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread, - ), - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 2, - refs: [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: null, - }, - { - name: "release/next", - current: false, - isDefault: false, - worktreePath: null, - }, - ], - }; - } - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - (await waitForButtonByText("Current checkout")).click(); - await page.getByText("New worktree", { exact: true }).click(); - await page.getByText("From main", { exact: true }).click(); - await page.getByText("release/next", { exact: true }).click(); - - await vi.waitFor( - () => { - expect(findButtonByText("From release/next")).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - - useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); - await waitForLayout(); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - const turnStartRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.turn.start", - ) as - | { - _tag: string; - type?: string; - bootstrap?: { - prepareWorktree?: { baseBranch?: string }; - }; - } - | undefined; - - expect(turnStartRequest?.bootstrap?.prepareWorktree?.baseBranch).toBe("release/next"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("clears pending worktree overrides when switching empty server threads", async () => { - const secondThreadId = "thread-browser-test-second" as ThreadId; - const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); - const snapshotWithSecondThread = addThreadToSnapshot(snapshot, secondThreadId); - const snapshotWithTwoThreads = { - ...snapshotWithSecondThread, - threads: snapshotWithSecondThread.threads.map((thread) => { - if (thread.id === THREAD_ID) { - return Object.assign({}, thread, { session: null, title: "Thread alpha" }); - } - if (thread.id === secondThreadId) { - return Object.assign({}, thread, { session: null, title: "Thread beta" }); - } - return thread; - }), - }; - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: snapshotWithTwoThreads, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 2, - refs: [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: null, - }, - { - name: "release/next", - current: false, - isDefault: false, - worktreePath: null, - }, - ], - }; - } - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - (await waitForButtonByText("Current checkout")).click(); - await page.getByText("New worktree", { exact: true }).click(); - await page.getByText("From main", { exact: true }).click(); - await page.getByText("release/next", { exact: true }).click(); - - await vi.waitFor( - () => { - expect(findButtonByText("From release/next")).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - - await mounted.router.navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId: LOCAL_ENVIRONMENT_ID, - threadId: secondThreadId, - }, - }); - - await waitForURL( - mounted.router, - (path) => path === serverThreadPath(secondThreadId), - "Route should switch to the second empty server thread.", - ); - - await vi.waitFor( - () => { - expect(findButtonByText("Current checkout")).toBeTruthy(); - expect(findButtonByText("From release/next")).toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); - - (await waitForButtonByText("Current checkout")).click(); - await page.getByText("New worktree", { exact: true }).click(); - - await vi.waitFor( - () => { - expect(findButtonByText("From main")).toBeTruthy(); - expect(findButtonByText("From release/next")).toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows the send state once bootstrap dispatch is in flight", async () => { - useTerminalUiStateStore.setState({ - terminalUiStateByThreadKey: {}, - }); - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - let resolveDispatch!: (value: { sequence: number }) => void; - const dispatchPromise = new Promise<{ sequence: number }>((resolve) => { - resolveDispatch = resolve; - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ]), - resolveRpc: (body) => { - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return dispatchPromise; - } - return undefined; - }, - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); - await waitForLayout(); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - expect( - wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand), - ).toBe(true); - expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy(); - expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - resolveDispatch({ sequence: fixture.snapshot.snapshotSequence + 1 }); - await mounted.cleanup(); - } - }); - - it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-hotkey" as MessageId, - targetText: "hotkey target", - }), - }); - - try { - const initialModeButton = await waitForInteractionModeButton("Build"); - expect(initialModeButton.getAttribute("aria-label")).toContain("enter plan mode"); - expect(initialModeButton.hasAttribute("title")).toBe(false); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); - - expect((await waitForInteractionModeButton("Build")).getAttribute("aria-label")).toContain( - "enter plan mode", - ); - - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor( - async () => { - expect((await waitForInteractionModeButton("Plan")).getAttribute("aria-label")).toContain( - "return to normal build mode", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor( - async () => { - expect( - (await waitForInteractionModeButton("Build")).getAttribute("aria-label"), - ).toContain("enter plan mode"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("uses the configured diff toggle binding without discarding its surface", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-diff-hotkey" as MessageId, - targetText: "diff hotkey target", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "diff.toggle", - shortcut: { - key: "g", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: true, - modKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - dispatchConfiguredDiffToggleShortcut(); - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ - isOpen: true, - activeSurfaceId: "diff", - surfaces: [{ id: "diff", kind: "diff" }], - }); - }); - - dispatchConfiguredDiffToggleShortcut(); - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ - isOpen: false, - activeSurfaceId: "diff", - surfaces: [{ id: "diff", kind: "diff" }], - }); - }); - - dispatchConfiguredDiffToggleShortcut(); - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ - isOpen: true, - activeSurfaceId: "diff", - surfaces: [{ id: "diff", kind: "diff" }], - }); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("focuses the composer and inserts printable text typed from the page background", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-type-to-focus" as MessageId, - targetText: "type-to-focus target", - }), - }); - - const backgroundTarget = document.createElement("div"); - backgroundTarget.tabIndex = -1; - document.body.append(backgroundTarget); - - try { - const composerEditor = await waitForComposerEditor(); - backgroundTarget.focus(); - expect(document.activeElement).not.toBe(composerEditor); - - const event = new KeyboardEvent("keydown", { - key: "h", - bubbles: true, - cancelable: true, - }); - backgroundTarget.dispatchEvent(event); - - await waitForComposerText("h"); - expect(event.defaultPrevented).toBe(true); - expect(document.activeElement).toBe(composerEditor); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "i", - bubbles: true, - cancelable: true, - }), - ); - - await waitForComposerText("hi"); - } finally { - backgroundTarget.remove(); - await mounted.cleanup(); - } - }); - - it("does not steal printable keys from editable targets or shortcut modifiers", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-type-to-focus-guards" as MessageId, - targetText: "type-to-focus guards target", - }), - }); - const input = document.createElement("input"); - document.body.append(input); - - try { - input.focus(); - input.dispatchEvent( - new KeyboardEvent("keydown", { - key: "x", - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "k", - metaKey: true, - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); - } finally { - input.remove(); - await mounted.cleanup(); - } - }); - - it("uses the active draft route session when changing the base branch", async () => { - const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); - const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); - - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [staleDraftId]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - [activeDraftId]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId, - [PROJECT_DRAFT_KEY]: activeDraftId, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - initialPath: `/draft/${activeDraftId}`, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 2, - refs: [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: null, - }, - { - name: "release/next", - current: false, - isDefault: false, - worktreePath: null, - }, - ], - }; - } - return undefined; - }, - }); - - try { - const branchButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "From main", - ) as HTMLButtonElement | null, - 'Unable to find branch selector button with "From main".', - ); - branchButton.click(); - - const branchOption = await waitForElement( - () => - Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "release/next", - ) as HTMLSpanElement | null, - 'Unable to find the "release/next" branch option.', - ); - branchOption.click(); - - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe( - "release/next", - ); - expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe( - "main", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const updatedButton = Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.trim().includes("From release/next"), - ); - expect(updatedButton).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the new worktree branch picker anchored at the top when opening with a preselected branch", async () => { - const draftId = DraftId.make("draft-branch-picker-scroll-regression"); - const branches = [ - { - name: "feature/current", - current: true, - isDefault: false, - worktreePath: null, - }, - { - name: "main", - current: false, - isDefault: true, - worktreePath: null, - }, - ...Array.from({ length: 48 }, (_, index) => ({ - name: `feature/${String(index).padStart(2, "0")}`, - current: false, - isDefault: false, - worktreePath: null, - })), - { - name: "feature/selected", - current: false, - isDefault: false, - worktreePath: null, - }, - ]; - - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [draftId]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "feature/selected", - worktreePath: null, - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: draftId, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - initialPath: `/draft/${draftId}`, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: branches.length, - refs: branches, - }; - } - return undefined; - }, - }); - - try { - const branchButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "From feature/selected", - ) as HTMLButtonElement | null, - 'Unable to find branch selector button with "From feature/selected".', - ); - branchButton.click(); - - await waitForElement( - () => document.querySelector('input[placeholder="Search refs..."]'), - "Unable to find ref search input.", - ); - - const popup = await waitForElement( - () => document.querySelector('[data-slot="combobox-popup"]'), - "Unable to find the branch picker popup.", - ); - - await vi.waitFor( - () => { - const popupSpans = Array.from(popup.querySelectorAll("span")); - expect( - popupSpans.some((element) => element.textContent?.trim() === "feature/current"), - ).toBe(true); - expect(popupSpans.some((element) => element.textContent?.trim() === "main")).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-basic" as MessageId, - targetText: "surround basic", - }), - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); - await waitForComposerText("selected"); - await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length }); - await pressComposerKey("("); - await waitForComposerText("(selected)"); - - await pressComposerKey("["); - await waitForComposerText("([selected])"); - } finally { - await mounted.cleanup(); - } - }); - - it("leaves collapsed-caret typing unchanged for surround symbols", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-collapsed" as MessageId, - targetText: "surround collapsed", - }), - }); - - try { - await waitForComposerText("selected"); - await setComposerSelectionByTextOffsets({ - start: "selected".length, - end: "selected".length, - }); - await pressComposerKey("("); - await waitForComposerText("selected("); - } finally { - await mounted.cleanup(); - } - }); - - it("supports symmetric and backward-selection surrounds", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "backward"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-backward" as MessageId, - targetText: "surround backward", - }), - }); - - try { - await waitForComposerText("backward"); - await setComposerSelectionByTextOffsets({ - start: 0, - end: "backward".length, - direction: "backward", - }); - await pressComposerKey("*"); - await waitForComposerText("*backward*"); - } finally { - await mounted.cleanup(); - } - }); - - it("supports option-produced surround symbols like guillemets", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-guillemet" as MessageId, - targetText: "surround guillemet", - }), - }); - - try { - await waitForComposerText("quoted"); - await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); - await pressComposerKey("«"); - await waitForComposerText("«quoted»"); - } finally { - await mounted.cleanup(); - } - }); - - it("supports dead-key composition that resolves to another surround symbol without an extra undo step", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-dead-quote" as MessageId, - targetText: "surround dead quote", - }), - }); - - try { - await waitForComposerText("quoted"); - await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Dead", - bubbles: true, - cancelable: true, - }), - ); - composerEditor.dispatchEvent( - new InputEvent("beforeinput", { - data: "'", - inputType: "insertCompositionText", - bubbles: true, - cancelable: true, - }), - ); - const resolvedInputEvent = new InputEvent("beforeinput", { - data: "'", - inputType: "insertText", - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(resolvedInputEvent); - expect(resolvedInputEvent.defaultPrevented).toBe(true); - await waitForComposerText("'quoted'"); - await pressComposerUndo(); - await waitForComposerText("quoted"); - } finally { - await mounted.cleanup(); - } - }); - - it("surrounds text after a mention using the correct expanded offsets", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-after-mention" as MessageId, - targetText: "surround after mention", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("package.json"); - }, - { timeout: 8_000, interval: 16 }, - ); - await waitForComposerText("hi [package.json](package.json) there"); - await setComposerSelectionByTextOffsets({ - start: "hi package.json ".length, - end: "hi package.json there".length, - }); - await pressComposerKey("("); - await waitForComposerText("hi [package.json](package.json) (there)"); - } finally { - await mounted.cleanup(); - } - }); - - it("falls back to normal replacement when the selection includes a mention token", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there "); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-token" as MessageId, - targetText: "surround token", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("package.json"); - }, - { timeout: 8_000, interval: 16 }, - ); - await selectAllComposerContent(); - await pressComposerKey("("); - await waitForComposerText("("); - } finally { - await mounted.cleanup(); - } - }); - - it("stores selected file tags as markdown links while keeping the composer chip", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "@pack"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-file-tag-encoding" as MessageId, - targetText: "file tag encoding", - }), - resolveRpc: (body) => { - if (body._tag !== WS_METHODS.projectsSearchEntries) { - return undefined; - } - return { - entries: [ - { - path: "path/to/package.json", - kind: "file", - }, - ], - truncated: false, - }; - }, - }); - - try { - const item = await waitForComposerMenuItem("path:file:path/to/package.json"); - item.click(); - - await waitForComposerText("[package.json](path/to/package.json) "); - const chip = await waitForElement( - () => document.querySelector('[data-composer-mention-chip="true"]'), - "Unable to find rendered composer file chip.", - ); - expect(chip.textContent).toContain("package.json"); - } finally { - await mounted.cleanup(); - } - }); - - it("shows runtime mode descriptions in the desktop composer access select", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - }); - - try { - const runtimeModeSelect = await waitForButtonByText("Full access"); - runtimeModeSelect.click(); - - expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain( - "Ask before commands and file changes", - ); - - const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits"); - expect(autoAcceptItem.textContent).toContain("Auto-approve edits"); - expect((await waitForSelectItemContainingText("Full access")).textContent).toContain( - "Allow commands and edits without prompts", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps removed terminal context pills removed when a new one is added", async () => { - const removedLabel = "Terminal 1 lines 1-2"; - const addedLabel = "Terminal 2 lines 9-10"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-removed", - terminalLabel: "Terminal 1", - lineStart: 1, - lineEnd: 2, - text: "bun i\nno changes", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, - targetText: "terminal pill backspace target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; - const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_REF, nextPrompt.prompt); - store.removeTerminalContext(THREAD_REF, "ctx-removed"); - - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-added", - terminalLabel: "Terminal 2", - lineStart: 9, - lineEnd: 10, - text: "git status\nOn branch main", - }), - ); - - await vi.waitFor( - () => { - const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; - expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); - expect(document.body.textContent).toContain(addedLabel); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("disables send when the composer only contains an expired terminal pill", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-expired-only", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-disabled" as MessageId, - targetText: "expired pill disabled target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(true); - } finally { - await mounted.cleanup(); - } - }); - - it("warns when sending text while omitting expired terminal pills", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-expired-send-warning", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - useComposerDraftStore - .getState() - .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-warning" as MessageId, - targetText: "expired pill warning target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "Expired terminal context omitted from message", - ); - expect(document.body.textContent).not.toContain(expiredLabel); - expect(document.body.textContent).toContain("yoowaddup"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows a pointer cursor for the running stop button", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-stop-button-cursor" as MessageId, - targetText: "stop button cursor target", - sessionStatus: "running", - }), - }); - - try { - const stopButton = await waitForElement( - () => document.querySelector('button[aria-label="Stop generation"]'), - "Unable to find stop generation button.", - ); - - expect(getComputedStyle(stopButton).cursor).toBe("pointer"); - } finally { - await mounted.cleanup(); - } - }); - - it("hides the archive action when the pointer leaves a thread row", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-archive-hover-test" as MessageId, - targetText: "archive hover target", - }), - }); - - try { - const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); - - await expect.element(threadRow).toBeInTheDocument(); - const archiveButton = await waitForElement( - () => - document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`), - "Unable to find archive button.", - ); - const archiveAction = archiveButton.parentElement; - expect( - archiveAction, - "Archive button should render inside a visibility wrapper.", - ).not.toBeNull(); - expect(getComputedStyle(archiveAction!).opacity).toBe("0"); - - await threadRow.hover(); - await vi.waitFor( - () => { - expect(getComputedStyle(archiveAction!).opacity).toBe("1"); - }, - { timeout: 4_000, interval: 16 }, - ); - - await page.getByTestId("composer-editor").hover(); - await vi.waitFor( - () => { - expect(getComputedStyle(archiveAction!).opacity).toBe("0"); - }, - { timeout: 4_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("exposes the full thread title on the sidebar row tooltip", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-thread-tooltip-target" as MessageId, - targetText: "thread tooltip target", - }), - }); - - try { - const threadTitle = page.getByTestId(`thread-title-${THREAD_ID}`); - - await expect.element(threadTitle).toBeInTheDocument(); - await threadTitle.hover(); - - await vi.waitFor( - () => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip).not.toBeNull(); - expect(tooltip?.textContent).toContain(THREAD_TITLE); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows the sidebar terminal indicator from terminal metadata activity", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-metadata-indicator" as MessageId, - targetText: "terminal metadata indicator target", - }), - configureFixture: (nextFixture) => { - nextFixture.terminalMetadataEvents = [ - { - type: "upsert", - terminal: { - threadId: THREAD_ID, - terminalId: DEFAULT_TERMINAL_ID, - cwd: "/repo/project", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - hasRunningSubprocess: true, - label: "Terminal 1", - updatedAt: isoAt(1_200), - }, - }, - ]; - }, - }); - - try { - await vi.waitFor( - () => { - expect( - terminalSessionManager.listSessions({ - environmentId: LOCAL_ENVIRONMENT_ID, - threadId: THREAD_ID, - }), - ).toMatchObject([ - { - state: { - hasRunningSubprocess: true, - }, - }, - ]); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const terminalIndicator = document.querySelector( - '[aria-label="Terminal process running"]', - ); - expect(terminalIndicator).not.toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows the confirm archive action after clicking the archive button", async () => { - localStorage.setItem( - "t3code:client-settings:v1", - JSON.stringify({ - ...DEFAULT_CLIENT_SETTINGS, - confirmThreadArchive: true, - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-archive-confirm-test" as MessageId, - targetText: "archive confirm target", - }), - }); - - try { - const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); - - await expect.element(threadRow).toBeInTheDocument(); - await threadRow.hover(); - - const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`); - await expect.element(archiveButton).toBeInTheDocument(); - await archiveButton.click(); - - const confirmButton = page.getByTestId(`thread-archive-confirm-${THREAD_ID}`); - await expect.element(confirmButton).toBeInTheDocument(); - await expect.element(confirmButton).toBeVisible(); - } finally { - localStorage.removeItem("t3code:client-settings:v1"); - await mounted.cleanup(); - } - }); - - it("canonicalizes promoted draft threads to the server thread route", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-thread-test" as MessageId, - targetText: "new thread selection test", - }), - }); - - try { - // Wait for the sidebar to render with the project. - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - // The route should change to a new draft thread ID. - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - const newThreadId = draftThreadIdFor(newDraftId); - - // The composer editor should be present for the new draft thread. - await waitForComposerEditor(); - - // `thread.created` should only mark the draft as promoting; it should - // not navigate away until the server thread has actual runtime state. - await materializePromotedDraftThreadViaDomainEvent(newThreadId); - expect(mounted.router.state.location.pathname).toBe(newThreadPath); - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); - - // Once the server thread starts, the route should canonicalize. - await startPromotedServerThreadViaDomainEvent(newThreadId); - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( - undefined, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - // The route should switch to the canonical server thread path. - await waitForURL( - mounted.router, - (path) => path === serverThreadPath(newThreadId), - "Promoted drafts should canonicalize to the server thread route.", - ); - - // The composer should remain usable after canonicalization, regardless of - // whether the promoted thread is still visibly empty or has already - // entered the running state. - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("canonicalizes stale promoted draft routes to the server thread route", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, - targetText: "draft hydration race test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - const newThreadId = draftThreadIdFor(newDraftId); - - await promoteDraftThreadViaDomainEvent(newThreadId); - - await mounted.router.navigate({ - to: "/draft/$draftId", - params: { draftId: newDraftId }, - }); - - await waitForURL( - mounted.router, - (path) => path === serverThreadPath(newThreadId), - "Stale promoted draft routes should canonicalize to the server thread path.", - ); - - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a fresh worktree draft from an existing worktree thread when the default mode is worktree", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, - targetText: "new thread worktree default test", - }), - threads: createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, - targetText: "new thread worktree default test", - }).threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - branch: "feature/existing", - worktreePath: "/repo/.t3/worktrees/existing", - }) - : thread, - ), - }, - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - settings: { - ...nextFixture.serverConfig.settings, - defaultThreadEnvMode: "worktree", - }, - }; - }, - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should change to a new draft thread.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - - expect(useComposerDraftStore.getState().getDraftSession(newDraftId)).toMatchObject({ - envMode: "worktree", - worktreePath: null, - }); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a new draft instead of reusing a promoting draft thread", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-promoting-draft-new-thread-test" as MessageId, - targetText: "promoting draft new thread test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const firstDraftPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should change to the first draft thread.", - ); - const firstDraftId = draftIdFromPath(firstDraftPath); - const firstThreadId = draftThreadIdFor(firstDraftId); - - await materializePromotedDraftThreadViaDomainEvent(firstThreadId); - expect(mounted.router.state.location.pathname).toBe(firstDraftPath); - - await newThreadButton.click(); - - const secondDraftPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path) && path !== firstDraftPath, - "Route should change to a second draft thread instead of reusing the promoting draft.", - ); - expect(draftIdFromPath(secondDraftPath)).not.toBe(firstDraftId); - } finally { - await mounted.cleanup(); - } - }); - - it("snapshots sticky codex settings into a new draft thread", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - [ProviderInstanceId.make("codex")]: createModelSelection( - ProviderInstanceId.make("codex"), - "gpt-5.3-codex", - [ - { id: "reasoningEffort", value: "medium" }, - { id: "fastMode", value: true }, - ], - ), - }, - stickyActiveProvider: ProviderInstanceId.make("codex"), - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, - targetText: "sticky codex traits test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - - // `toMatchObject` matches objects loosely (extras ignored) but compares - // arrays strictly, so wrap `options` in `arrayContaining` to keep the - // assertion focused on sticky `fastMode` carrying over without asserting - // on exactly which other options are preserved. - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex", - options: expect.arrayContaining([{ id: "fastMode", value: true }]), - }, - }, - activeProvider: "codex", - }); - } finally { - await mounted.cleanup(); - } - }); - - it("hydrates the provider alongside a sticky claude model", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - [ProviderInstanceId.make("claudeAgent")]: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ], - ), - }, - stickyActiveProvider: ProviderInstanceId.make("claudeAgent"), - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, - targetText: "sticky claude model test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new sticky claude draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - claudeAgent: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ], - ), - }, - activeProvider: "claudeAgent", - }); - } finally { - await mounted.cleanup(); - } - }); - - it("falls back to defaults when no sticky composer settings exist", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-default-codex-traits-test" as MessageId, - targetText: "default codex traits test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - - expect(composerDraftFor(newDraftId)).toBe(undefined); - } finally { - await mounted.cleanup(); - } - }); - - it("prefers draft state over sticky composer settings and defaults", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - [ProviderInstanceId.make("codex")]: createModelSelection( - ProviderInstanceId.make("codex"), - "gpt-5.3-codex", - [ - { id: "reasoningEffort", value: "medium" }, - { id: "fastMode", value: true }, - ], - ), - }, - stickyActiveProvider: ProviderInstanceId.make("codex"), - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, - targetText: "draft codex traits precedence test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const threadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a sticky draft thread UUID.", - ); - const draftId = draftIdFromPath(threadPath); - - // See the note on the sibling sticky-codex test: arrays match strictly - // under `toMatchObject`, so use `arrayContaining` to keep the assertion - // scoped to the sticky trait (`fastMode`) that must carry over. - expect(composerDraftFor(draftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex", - options: expect.arrayContaining([{ id: "fastMode", value: true }]), - }, - }, - activeProvider: "codex", - }); - - useComposerDraftStore.getState().setModelSelection( - draftId, - createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "low" }, - { id: "fastMode", value: true }, - ]), - ); - - await newThreadButton.click(); - - await waitForURL( - mounted.router, - (path) => path === threadPath, - "New-thread should reuse the existing project draft thread.", - ); - expect(composerDraftFor(draftId)).toMatchObject({ - modelSelectionByProvider: { - codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "low" }, - { id: "fastMode", value: true }, - ]), - }, - activeProvider: "codex", - }); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a new thread from the global chat.new shortcut", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-chat-shortcut-test" as MessageId, - targetText: "chat shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - { - command: "thread.jump.1", - shortcut: { - key: "1", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: false, - }, - }, - { - command: "modelPicker.jump.1", - shortcut: { - key: "1", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: false, - }, - whenAst: { type: "identifier", name: "modelPickerOpen" }, - }, - ], - }; - }, - }); - - try { - await waitForNewThreadShortcutLabel(); - await waitForServerConfigToApply(); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - await waitForLayout(); - await triggerChatNewShortcutUntilPath( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the shortcut.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("does not consume chat.new when there is no project context", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createProjectlessSnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - dispatchChatNewShortcut(); - await waitForLayout(); - - expect(mounted.router.state.location.pathname).toBe(serverThreadPath(THREAD_ID)); - expect(Object.keys(useComposerDraftStore.getState().draftThreadsByThreadKey)).toHaveLength(0); - } finally { - await mounted.cleanup(); - } - }); - - it("renders the configurable shortcut and runs a command from the sidebar trigger", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId, - targetText: "command palette shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await expect - .element(palette.getByText("New thread in Project", { exact: true })) - .toBeInTheDocument(); - await palette.getByText("New thread in Project", { exact: true }).click(); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the command palette.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("filters command palette results as the user types", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-search-test" as MessageId, - targetText: "command palette search test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); - await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("New thread in Project", { exact: true })) - .not.toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("adds a project from browse mode with Enter when no directory is highlighted", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-enter" as MessageId, - targetText: "command palette add project enter", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Development/") { - return { - parentPath: "~/Development/", - entries: [ - { name: "alpha", fullPath: "~/Development/alpha" }, - { name: "beta", fullPath: "~/Development/beta" }, - ], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); - await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); - - await expect - .element(palette.getByRole("button", { name: "Add (Enter)" })) - .toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "~/Development", - title: "Development", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a project with Enter.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows clone destination controls after resolving an add project repository", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-remote" as MessageId, - targetText: "command palette add project remote", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - if (body._tag === WS_METHODS.sourceControlLookupRepository) { - return { - provider: "github", - nameWithOwner: "t3-oss/t3-env", - url: "https://github.com/t3-oss/t3-env", - sshUrl: "git@github.com:t3-oss/t3-env.git", - }; - } - - if (body._tag === WS_METHODS.sourceControlCloneRepository) { - return { - cwd: body.destinationPath, - remoteUrl: body.remoteUrl, - repository: null, - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await palette.getByText("GitHub repository", { exact: true }).click(); - - const repositoryInput = await waitForCommandPaletteInput( - "Enter GitHub repository (owner/repo)", - ); - await page.getByPlaceholder("Enter GitHub repository (owner/repo)").fill("t3-oss/t3-env"); - await dispatchInputKey(repositoryInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const clonePathInput = document.querySelector( - 'input[placeholder="Enter path (e.g. ~/projects/my-app)"]', - ); - expect(clonePathInput?.value).toBe("~/"); - expect(document.body.textContent).toContain("Repository"); - expect(document.body.textContent).toContain("t3-oss/t3-env"); - expect(document.body.textContent).toContain("https://github.com/t3-oss/t3-env"); - expect(document.body.textContent).toContain("Select where to clone"); - expect(document.body.textContent).toContain("Development"); - expect(document.body.textContent).toContain("Clone"); - }, - { timeout: 8_000, interval: 16 }, - ); - - await page - .getByPlaceholder("Enter path (e.g. ~/projects/my-app)") - .fill("~/Development/t3env"); - const clonePathInput = await waitForCommandPaletteInput( - "Enter path (e.g. ~/projects/my-app)", - ); - await dispatchInputKey(clonePathInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const cloneRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.sourceControlCloneRepository, - ) as { destinationPath?: string; remoteUrl?: string } | undefined; - expect(cloneRequest).toMatchObject({ - remoteUrl: "git@github.com:t3-oss/t3-env.git", - destinationPath: "~/Development/t3env", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("opens add project browse mode from the sidebar add button", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sidebar-add-project-trigger" as MessageId, - targetText: "sidebar add project trigger", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - - await page.getByTestId("sidebar-add-project-trigger").click(); - - const palette = page.getByTestId("command-palette"); - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await expect.element(browseInput).toHaveValue("~/"); - - await vi.waitFor( - () => { - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.filesystemBrowse && request.partialPath === "~/", - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("starts add project browse mode from the configured base directory", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sidebar-add-project-custom-base-dir" as MessageId, - targetText: "sidebar add project custom base directory", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - settings: { - ...nextFixture.serverConfig.settings, - addProjectBaseDirectory: "~/Development", - }, - }; - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Development/") { - return { - parentPath: "~/Development/", - entries: [{ name: "codething", fullPath: "~/Development/codething" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - - await page.getByTestId("sidebar-add-project-trigger").click(); - - const palette = page.getByTestId("command-palette"); - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await expect.element(browseInput).toHaveValue("~/Development/"); - - await vi.waitFor( - () => { - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.filesystemBrowse && - request.partialPath === "~/Development/", - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows create-folder affordances for missing project paths", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-create-missing-project" as MessageId, - targetText: "command palette create missing project", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Desktop/") { - return { - parentPath: "~/Desktop/", - entries: [{ name: "existing", fullPath: "~/Desktop/existing" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Desktop", fullPath: "~/Desktop" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - const palette = page.getByTestId("command-palette"); - await page.getByTestId("sidebar-add-project-trigger").click(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); - - await expect - .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) - .toBeInTheDocument(); - await expect.element(palette.getByText("Will create this folder")).not.toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - createWorkspaceRootIfMissing?: boolean; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "~/Desktop/fresh-project", - title: "fresh-project", - createWorkspaceRootIfMissing: true, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("does not show create affordances for an existing directory with a trailing slash", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-existing-trailing-directory" as MessageId, - targetText: "command palette existing trailing directory", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Development/codex/") { - return { - parentPath: "~/Development/codex/", - entries: [{ name: "Codex.app", fullPath: "~/Development/codex/Codex.app" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - const palette = page.getByTestId("command-palette"); - await page.getByTestId("sidebar-add-project-trigger").click(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); - - await vi.waitFor( - () => { - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.filesystemBrowse && - request.partialPath === "~/Development/codex/", - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - - await expect - .element(palette.getByRole("button", { name: "Add (Enter)" })) - .toBeInTheDocument(); - await expect - .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) - .not.toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "~/Development/codex", - title: "codex", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("selects an environment before browsing when multiple environments are available", async () => { - const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => { - if (partialPath === "~/workspaces/") { - return { - parentPath: "~/workspaces/", - entries: [{ name: "codething", fullPath: "~/workspaces/codething" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "workspaces", fullPath: "~/workspaces" }], - }; - }); - const remoteDispatchMock = vi.fn(async () => ({ - sequence: fixture.snapshot.snapshotSequence + 1, - })); - - __setEnvironmentApiOverrideForTests( - REMOTE_ENVIRONMENT_ID, - createMockEnvironmentApi({ - browse: remoteBrowseMock, - dispatchCommand: remoteDispatchMock, - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId, - targetText: "command palette add project multi env", - }), - }); - - try { - await waitForServerConfigToApply(); - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - httpBaseUrl: "https://staging.example.test", - wsBaseUrl: "wss://staging.example.test/ws", - createdAt: NOW_ISO, - lastConnectedAt: NOW_ISO, - }); - useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { - connectionState: "connected", - authState: "authenticated", - descriptor: { - ...fixture.serverConfig.environment, - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - }, - serverConfig: { - ...fixture.serverConfig, - environment: { - ...fixture.serverConfig.environment, - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - }, - settings: { - ...fixture.serverConfig.settings, - addProjectBaseDirectory: "~/workspaces", - }, - }, - connectedAt: NOW_ISO, - }); - - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("This device", { exact: true }).first()) - .toBeInTheDocument(); - await palette.getByText("Staging", { exact: true }).click(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await expect.element(browseInput).toHaveValue("~/workspaces/"); - - await vi.waitFor( - () => { - expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/"); - await vi.waitFor( - () => { - expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); - }, - { timeout: 8_000, interval: 16 }, - ); - await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByRole("button", { name: "Add (Enter)" })) - .toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - expect(remoteDispatchMock).toHaveBeenCalledWith( - expect.objectContaining({ - type: "project.create", - workspaceRoot: "~/workspaces", - title: "workspaces", - }), - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a remote project.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("picks a local project from the native file manager", async () => { - const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-file-manager" as MessageId, - targetText: "command palette add project file manager", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Applications/") { - return { - parentPath: "~/Applications/", - entries: [{ name: "Utilities", fullPath: "~/Applications/Utilities" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Applications", fullPath: "~/Applications" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - window.desktopBridge = { - pickFolder, - setTheme: vi.fn().mockResolvedValue(undefined), - } as unknown as NonNullable; - - await page.getByTestId("sidebar-add-project-trigger").click(); - - const palette = page.getByTestId("command-palette"); - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await browseInput.fill("~/Applications/access"); - - const fileManagerLabel = isMacPlatform(navigator.platform) - ? "Open in Finder" - : navigator.platform.toLowerCase().startsWith("win") - ? "Open in Explorer" - : "Open in Files"; - await palette.getByRole("button", { name: fileManagerLabel }).click(); - - await vi.waitFor( - () => { - expect(pickFolder).toHaveBeenCalledWith({ initialPath: "~/Applications" }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "/Users/julius/Projects/finder-picked", - title: "finder-picked", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a project from the native file manager.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("adds a project from browse mode with Mod+Enter when a directory is highlighted", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-mod-enter" as MessageId, - targetText: "command palette add project mod enter", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Development/") { - return { - parentPath: "~/Development/", - entries: [ - { name: "alpha", fullPath: "~/Development/alpha" }, - { name: "beta", fullPath: "~/Development/beta" }, - ], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); - await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "ArrowDown" }); - - const addButtonLabel = isMacPlatform(navigator.platform) - ? "Add (\u2318 Enter)" - : "Add (Ctrl Enter)"; - await vi.waitFor( - () => { - const legendEntries = getCommandPaletteLegendEntries(); - expect(legendEntries).toContain("Enter Select"); - }, - { timeout: 8_000, interval: 16 }, - ); - await expect - .element(palette.getByRole("button", { name: addButtonLabel })) - .toBeInTheDocument(); - - await dispatchInputKey(browseInput, { - key: "Enter", - metaKey: isMacPlatform(navigator.platform), - ctrlKey: !isMacPlatform(navigator.platform), - }); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "~/Development", - title: "Development", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a project with Mod+Enter.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps project-context thread matches available when searching by project name", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithSecondaryProject(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs"); - await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("Release checklist", { exact: true })) - .toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("searches projects by path and opens the latest thread for that project", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithSecondaryProject(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - settings: { - ...nextFixture.serverConfig.settings, - defaultThreadEnvMode: "worktree", - }, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); - await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) - .toBeInTheDocument(); - await palette.getByText("Docs Portal", { exact: true }).click(); - - const nextPath = await waitForURL( - mounted.router, - (path) => path === serverThreadPath("thread-secondary-project" as ThreadId), - "Route should have changed to the latest thread for the selected project.", - ); - expect(nextPath).toBe(serverThreadPath("thread-secondary-project" as ThreadId)); - expect( - useComposerDraftStore - .getState() - .getDraftThread(threadRefFor("thread-secondary-project" as ThreadId)), - ).toBeNull(); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a new thread from project search when no active project thread exists", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithSecondaryProject({ includeSecondaryThread: false }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - settings: { - ...nextFixture.serverConfig.settings, - defaultThreadEnvMode: "worktree", - }, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); - await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) - .toBeInTheDocument(); - await palette.getByText("Docs Portal", { exact: true }).click(); - - const nextPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the project search result.", - ); - const nextDraftId = draftIdFromPath(nextPath); - const draftThread = useComposerDraftStore.getState().getDraftSession(nextDraftId); - expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); - expect(draftThread?.envMode).toBe("worktree"); - } finally { - await mounted.cleanup(); - } - }); - - it("filters archived threads out of command palette search results", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithSecondaryProject(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs-archive"); - await expect - .element(palette.getByText("Archived Docs Notes", { exact: true })) - .not.toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a fresh draft after the previous draft thread is promoted", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId, - targetText: "promoted draft shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - await waitForServerConfigToApply(); - await newThreadButton.click(); - - const promotedThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a promoted draft thread UUID.", - ); - const promotedDraftId = draftIdFromPath(promotedThreadPath); - const promotedThreadId = draftThreadIdFor(promotedDraftId); - - await promoteDraftThreadViaDomainEvent(promotedThreadId); - await waitForURL( - mounted.router, - (path) => path === serverThreadPath(promotedThreadId), - "Promoted drafts should canonicalize to the server thread route before a fresh draft is created.", - ); - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().getDraftThread(promotedDraftId)).toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - await waitForLayout(); - - const freshThreadPath = await triggerChatNewShortcutUntilPath( - mounted.router, - (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, - "Shortcut should create a fresh draft instead of reusing the promoted thread.", - ); - expect(freshThreadPath).not.toBe(promotedThreadPath); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps long proposed plans lightweight until the user expands them", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithLongProposedPlan(), - }); - - try { - await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Expand plan", - ) as HTMLButtonElement | null, - "Unable to find Expand plan button.", - ); - - expect(document.body.textContent).not.toContain("deep hidden detail only after expand"); - - const expandButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Expand plan", - ) as HTMLButtonElement | null, - "Unable to find Expand plan button.", - ); - expandButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("deep hidden detail only after expand"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("uses the active worktree path when saving a proposed plan to the workspace", async () => { - const snapshot = createSnapshotWithLongProposedPlan(); - const threads = snapshot.threads.slice(); - const targetThreadIndex = threads.findIndex((thread) => thread.id === THREAD_ID); - const targetThread = targetThreadIndex >= 0 ? threads[targetThreadIndex] : undefined; - if (targetThread) { - threads[targetThreadIndex] = { - ...targetThread, - worktreePath: "/repo/worktrees/plan-thread", - }; - } - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - threads, - }, - }); - - try { - const planActionsButton = await waitForElement( - () => document.querySelector('button[aria-label="Plan actions"]'), - "Unable to find proposed plan actions button.", - ); - planActionsButton.click(); - - const saveToWorkspaceItem = await waitForElement( - () => - (Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find( - (item) => item.textContent?.trim() === "Save to workspace", - ) ?? null) as HTMLElement | null, - 'Unable to find "Save to workspace" menu item.', - ); - saveToWorkspaceItem.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "Enter a path relative to /repo/worktrees/plan-thread.", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps pending-question footer actions inside the composer after a real resize", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPendingUserInput(), - }); - - try { - const firstOption = await waitForButtonContainingText("Tight"); - firstOption.click(); - - await waitForButtonByText("Previous"); - await waitForButtonByText("Submit answers"); - - await mounted.setContainerSize(COMPACT_FOOTER_VIEWPORT); - await expectComposerActionsContained(); - } finally { - await mounted.cleanup(); - } - }); - - it("submits pending user input after the final option selection resolves the draft answers", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithPendingUserInput(), - resolveRpc: (body) => { - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - const firstOption = await waitForButtonContainingText("Tight"); - firstOption.click(); - - const finalOption = await waitForButtonContainingText("Conservative"); - finalOption.click(); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.user-input.respond", - ) as - | { - _tag: string; - type?: string; - requestId?: string; - answers?: Record; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "thread.user-input.respond", - requestId: "req-browser-user-input", - answers: { - scope: "Tight", - risk: "Conservative", - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPlanFollowUpPrompt(), - }); - - try { - const footer = await waitForElement( - () => document.querySelector('[data-chat-composer-footer="true"]'), - "Unable to find composer footer.", - ); - const initialModelPicker = await waitForElement( - findComposerProviderModelPicker, - "Unable to find provider model picker.", - ); - const initialModelPickerOffset = - initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left; - const initialImplementButton = await waitForButtonByText("Implement"); - const initialImplementWidth = initialImplementButton.getBoundingClientRect().width; - - await waitForElement( - () => - document.querySelector('button[aria-label="Implementation actions"]'), - "Unable to find implementation actions trigger.", - ); - - await mounted.setContainerSize({ - width: 440, - height: WIDE_FOOTER_VIEWPORT.height, - }); - await expectComposerActionsContained(); - - const implementButton = await waitForButtonByText("Implement"); - const implementActionsButton = await waitForElement( - () => - document.querySelector('button[aria-label="Implementation actions"]'), - "Unable to find implementation actions trigger.", - ); - - await vi.waitFor( - () => { - const implementRect = implementButton.getBoundingClientRect(); - const implementActionsRect = implementActionsButton.getBoundingClientRect(); - const compactModelPicker = findComposerProviderModelPicker(); - expect(compactModelPicker).toBeTruthy(); - - const compactModelPickerOffset = - compactModelPicker!.getBoundingClientRect().left - footer.getBoundingClientRect().left; - - expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1); - expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1); - expect(Math.abs(implementRect.width - initialImplementWidth)).toBeLessThanOrEqual(1); - expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual( - 1, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the wide desktop follow-up layout expanded when the footer still fits", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex-spark", - }, - planMarkdown: - "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", - }), - }); - - try { - await waitForButtonByText("Implement"); - - await vi.waitFor( - () => { - const footer = document.querySelector('[data-chat-composer-footer="true"]'); - const actions = document.querySelector( - '[data-chat-composer-actions="right"]', - ); - - expect(footer?.dataset.chatComposerFooterCompact).toBe("false"); - expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("false"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("compacts the footer when a wide desktop follow-up layout starts overflowing", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex-spark", - }, - planMarkdown: - "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", - }), - }); - - try { - await waitForButtonByText("Implement"); - - await mounted.setContainerSize({ - width: 804, - height: WIDE_FOOTER_VIEWPORT.height, - }); - - await expectComposerActionsContained(); - - await vi.waitFor( - () => { - const footer = document.querySelector('[data-chat-composer-footer="true"]'); - const actions = document.querySelector( - '[data-chat-composer-actions="right"]', - ); - - expect(footer?.dataset.chatComposerFooterCompact).toBe("true"); - expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("true"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the slash-command menu visible above the composer", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-menu-target" as MessageId, - targetText: "command menu thread", - }), - }); - - try { - await waitForComposerEditor(); - await page.getByTestId("composer-editor").fill("/"); - - const menuItem = await waitForComposerMenuItem("slash:model"); - const composerForm = await waitForElement( - () => document.querySelector('[data-chat-composer-form="true"]'), - "Unable to find composer form.", - ); - - await vi.waitFor( - () => { - const menuRect = menuItem.getBoundingClientRect(); - const composerRect = composerForm.getBoundingClientRect(); - const hitTarget = document.elementFromPoint( - menuRect.left + menuRect.width / 2, - menuRect.top + menuRect.height / 2, - ); - - expect(menuRect.width).toBeGreaterThan(0); - expect(menuRect.height).toBeGreaterThan(0); - expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom); - expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("opens the model picker when selecting /model", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-model-command-target" as MessageId, - targetText: "model command thread", - }), - }); - - try { - await waitForComposerEditor(); - await page.getByTestId("composer-editor").fill("/mod"); - - const menuItem = await waitForComposerMenuItem("slash:model"); - await menuItem.click(); - - await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).not.toBeNull(); - expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model"); - }); - - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - - await vi.waitFor(() => { - const searchInput = document.querySelector( - 'input[placeholder="Search models..."]', - ); - expect(searchInput).not.toBeNull(); - expect(document.activeElement).toBe(searchInput); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("toggles the model picker and shows jump keys immediately from the shortcut", async () => { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId, - targetText: "model picker shortcut thread", - }); - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - projects: snapshot.projects.map((project) => - project.id === PROJECT_ID - ? Object.assign({}, project, { - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - }) - : project, - ), - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - }) - : thread, - ), - }, - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "modelPicker.toggle", - shortcut: { - key: "m", - metaKey: false, - ctrlKey: true, - shiftKey: true, - altKey: false, - modKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - { - command: "thread.jump.1", - shortcut: { - key: "1", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - modKey: false, - }, - }, - { - command: "modelPicker.jump.1", - shortcut: { - key: "1", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - modKey: false, - }, - whenAst: { type: "identifier", name: "modelPickerOpen" }, - }, - ], - providers: [ - { - ...nextFixture.serverConfig.providers[0]!, - models: [ - { - slug: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - ], - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForComposerEditor(); - - const initialPath = mounted.router.state.location.pathname; - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "m", - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).not.toBeNull(); - }); - - const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1"; - await vi.waitFor(() => { - expect( - Array.from( - document.querySelectorAll('.model-picker-list [data-slot="kbd"]'), - ).some((element) => element.textContent?.trim() === jumpLabel), - ).toBe(true); - }); - expect(mounted.router.state.location.pathname).toBe(initialPath); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "m", - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).toBeNull(); - }); - } finally { - releaseModShortcut("Control"); - await mounted.cleanup(); - } - }); - - it("shows a tooltip with the skill description when hovering a skill pill", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-skill-tooltip-target" as MessageId, - targetText: "skill tooltip thread", - }), - configureFixture: (nextFixture) => { - const provider = nextFixture.serverConfig.providers[0]; - if (!provider) { - throw new Error("Expected default provider in test fixture."); - } - ( - provider as { - skills: ServerConfig["providers"][number]["skills"]; - } - ).skills = [ - { - name: "agent-browser", - displayName: "Agent Browser", - description: "Open pages, click around, and inspect web apps.", - path: "/Users/test/.agents/skills/agent-browser/SKILL.md", - enabled: true, - }, - ]; - }, - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "use the $agent-browser "); - await waitForComposerText("use the $agent-browser "); - - await waitForElement( - () => document.querySelector('[data-composer-skill-chip="true"]'), - "Unable to find rendered composer skill chip.", - ); - await page.getByText("Agent Browser").hover(); - - await vi.waitFor( - () => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip).not.toBeNull(); - expect(tooltip?.textContent).toContain("Open pages, click around, and inspect web apps."); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a7810929f9..b3adf4cadbb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,16 +1,7 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { - EnvironmentId, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { type EnvironmentState, useStore } from "../store"; -import { type Thread } from "../types"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import type { Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -23,10 +14,61 @@ import { reconcileRetainedMountedThreadIds, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, - waitForStartedServerThread, } from "./ChatView.logic"; -const localEnvironmentId = EnvironmentId.make("environment-local"); +const environmentId = EnvironmentId.make("environment-local"); +const projectId = ProjectId.make("project-1"); +const threadId = ThreadId.make("thread-1"); +const now = "2026-03-29T00:00:00.000Z"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: threadId, + environmentId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + goal: null, + ...overrides, + }; +} + +const completedTurn = { + turnId: TurnId.make("turn-1"), + state: "completed" as const, + requestedAt: now, + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: "2026-03-29T00:00:10.000Z", + assistantMessageId: null, +}; + +const readySession = { + threadId, + status: "ready" as const, + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-29T00:00:10.000Z", +}; describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { @@ -36,13 +78,13 @@ describe("deriveComposerSendState", () => { terminalContexts: [ { id: "ctx-expired", - threadId: ThreadId.make("thread-1"), + threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", - createdAt: "2026-03-17T12:52:29.000Z", + createdAt: now, }, ], }); @@ -60,13 +102,13 @@ describe("deriveComposerSendState", () => { terminalContexts: [ { id: "ctx-expired", - threadId: ThreadId.make("thread-1"), + threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", - createdAt: "2026-03-17T12:52:29.000Z", + createdAt: now, }, ], }); @@ -102,14 +144,11 @@ describe("deriveComposerSendState", () => { }); describe("buildExpiredTerminalContextToastCopy", () => { - it("formats clear empty-state guidance", () => { + it("formats empty and omission guidance", () => { expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ title: "Expired terminal context won't be sent", description: "Remove it or re-add it to include terminal output.", }); - }); - - it("formats omission guidance for sent messages", () => { expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ title: "Expired terminal contexts omitted from message", description: "Re-add it if you want that terminal output included.", @@ -185,94 +224,38 @@ describe("getStartedThreadModelChangeBlockReason", () => { }); describe("resolveSendEnvMode", () => { - it("keeps worktree mode for git repositories", () => { + it("keeps worktree mode only for git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); - }); - - it("forces local mode for non-git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); - expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local"); }); }); describe("reconcileMountedTerminalThreadIds", () => { - it("keeps previously mounted open threads and adds the active open thread", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-stale")], - openThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-active")], - activeThreadId: ThreadId.make("thread-active"), - activeThreadTerminalOpen: true, - }), - ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); - }); - - it("drops mounted threads once their terminal drawer is no longer open", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-closed")], - openThreadIds: [], - activeThreadId: ThreadId.make("thread-closed"), - activeThreadTerminalOpen: false, - }), - ).toEqual([]); - }); - - it("keeps only the most recently active hidden terminal threads", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ], - openThreadIds: [ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ThreadId.make("thread-4"), - ], - activeThreadId: ThreadId.make("thread-4"), - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, - }), - ).toEqual([ThreadId.make("thread-2"), ThreadId.make("thread-3"), ThreadId.make("thread-4")]); - }); - - it("moves the active thread to the end so it is treated as most recently used", () => { + it("keeps open threads and makes the active thread most recent", () => { expect( reconcileMountedTerminalThreadIds({ - currentThreadIds: [ - ThreadId.make("thread-a"), - ThreadId.make("thread-b"), - ThreadId.make("thread-c"), - ], - openThreadIds: [ - ThreadId.make("thread-a"), - ThreadId.make("thread-b"), - ThreadId.make("thread-c"), - ], - activeThreadId: ThreadId.make("thread-a"), + currentThreadIds: ["thread-a", "thread-b", "thread-c"], + openThreadIds: ["thread-a", "thread-b", "thread-c"], + activeThreadId: "thread-a", activeThreadTerminalOpen: true, maxHiddenThreadCount: 2, }), - ).toEqual([ThreadId.make("thread-b"), ThreadId.make("thread-c"), ThreadId.make("thread-a")]); + ).toEqual(["thread-b", "thread-c", "thread-a"]); }); - it("defaults to the hidden mounted terminal cap", () => { - const currentThreadIds = Array.from( + it("drops closed threads and enforces the hidden mounted cap", () => { + const ids = Array.from( { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, - (_, index) => ThreadId.make(`thread-${index + 1}`), + (_, index) => `thread-${index}`, ); - expect( reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: currentThreadIds, + currentThreadIds: ids, + openThreadIds: ids.slice(1), activeThreadId: null, activeThreadTerminalOpen: false, }), - ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); + ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); }); }); @@ -321,322 +304,38 @@ describe("reconcileRetainedMountedThreadIds", () => { }); describe("shouldWriteThreadErrorToCurrentServerThread", () => { - it("routes errors to the active server thread when route and target match", () => { - const threadId = ThreadId.make("thread-1"); - const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); + it("requires the environment, route thread, and target thread to match", () => { + const routeThreadRef = { environmentId, threadId }; expect( shouldWriteThreadErrorToCurrentServerThread({ - serverThread: { - environmentId: localEnvironmentId, - id: threadId, - }, + serverThread: { environmentId, id: threadId }, routeThreadRef, targetThreadId: threadId, }), ).toBe(true); - }); - - it("does not route draft-thread errors into server-backed state", () => { - const threadId = ThreadId.make("thread-1"); - expect( shouldWriteThreadErrorToCurrentServerThread({ - serverThread: undefined, - routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), + serverThread: null, + routeThreadRef, targetThreadId: threadId, }), ).toBe(false); }); }); -const makeThread = (input?: { - id?: ThreadId; - latestTurn?: { - turnId: TurnId; - state: "running" | "completed"; - requestedAt: string; - startedAt: string | null; - completedAt: string | null; - } | null; -}): Thread => ({ - id: input?.id ?? ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access" as const, - interactionMode: "default" as const, - session: null, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:00.000Z", - latestTurn: input?.latestTurn - ? { - ...input.latestTurn, - assistantMessageId: null, - } - : null, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - goal: null, -}); - -function setStoreThreads(threads: ReadonlyArray>) { - const projectId = ProjectId.make("project-1"); - const environmentState: EnvironmentState = { - projectIds: [projectId], - projectById: { - [projectId]: { - id: projectId, - environmentId: localEnvironmentId, - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - createdAt: "2026-03-29T00:00:00.000Z", - updatedAt: "2026-03-29T00:00:00.000Z", - scripts: [], - }, - }, - threadIds: threads.map((thread) => thread.id), - threadIdsByProjectId: { - [projectId]: threads.map((thread) => thread.id), - }, - threadShellById: Object.fromEntries( - threads.map((thread) => [ - thread.id, - { - id: thread.id, - environmentId: thread.environmentId, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: thread.error, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - goal: thread.goal, - }, - ]), - ), - threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])), - threadTurnStateById: Object.fromEntries( - threads.map((thread) => [ - thread.id, - { - latestTurn: thread.latestTurn, - ...(thread.pendingSourceProposedPlan - ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } - : {}), - }, - ]), - ), - messageIdsByThreadId: Object.fromEntries( - threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]), - ), - messageByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - Object.fromEntries(thread.messages.map((message) => [message.id, message])), - ]), - ), - activityIdsByThreadId: Object.fromEntries( - threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]), - ), - activityByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])), - ]), - ), - proposedPlanIdsByThreadId: Object.fromEntries( - threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]), - ), - proposedPlanByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), - ]), - ), - turnDiffIdsByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - thread.turnDiffSummaries.map((summary) => summary.turnId), - ]), - ), - turnDiffSummaryByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])), - ]), - ), - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; - useStore.setState({ - activeEnvironmentId: localEnvironmentId, - environmentStateById: { - [localEnvironmentId]: environmentState, - }, - }); -} - -afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - setStoreThreads([]); -}); - -describe("waitForStartedServerThread", () => { - it("resolves immediately when the thread is already started", async () => { - const threadId = ThreadId.make("thread-started"); - setStoreThreads([ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.make("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ]); - - await expect( - waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), - ).resolves.toBe(true); - }); - - it("waits for the thread to start via subscription updates", async () => { - const threadId = ThreadId.make("thread-wait"); - setStoreThreads([makeThread({ id: threadId })]); - - const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); - - setStoreThreads([ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.make("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ]); - - await expect(promise).resolves.toBe(true); - }); - - it("handles the thread starting between the initial read and subscription setup", async () => { - const threadId = ThreadId.make("thread-race"); - setStoreThreads([makeThread({ id: threadId })]); - - const originalSubscribe = useStore.subscribe.bind(useStore); - let raced = false; - vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { - if (!raced) { - raced = true; - setStoreThreads([ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.make("turn-race"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ]); - } - return originalSubscribe(listener); - }); - - await expect( - waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500), - ).resolves.toBe(true); - }); - - it("returns false after the timeout when the thread never starts", async () => { - vi.useFakeTimers(); - - const threadId = ThreadId.make("thread-timeout"); - setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); - - await vi.advanceTimersByTimeAsync(500); - - await expect(promise).resolves.toBe(false); - }); -}); - describe("hasServerAcknowledgedLocalDispatch", () => { - const projectId = ProjectId.make("project-1"); - const previousLatestTurn = { - turnId: TurnId.make("turn-1"), - state: "completed" as const, - requestedAt: "2026-03-29T00:00:00.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: "2026-03-29T00:00:10.000Z", - assistantMessageId: null, - }; - - const previousSession = { - provider: ProviderDriverKind.make("codex"), - status: "ready" as const, - createdAt: "2026-03-29T00:00:00.000Z", - updatedAt: "2026-03-29T00:00:10.000Z", - orchestrationStatus: "idle" as const, - }; - - it("does not clear local dispatch before server state changes", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - goal: null, - }); + it("does not acknowledge unchanged server state", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); expect( hasServerAcknowledgedLocalDispatch({ localDispatch, phase: "ready", - latestTurn: previousLatestTurn, - session: previousSession, + latestTurn: completedTurn, + session: readySession, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -644,46 +343,24 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(false); }); - it("clears local dispatch when a new turn is already settled", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - goal: null, - }); + it("acknowledges a settled newer turn", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); + const newerTurn = { + ...completedTurn, + turnId: TurnId.make("turn-2"), + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: "2026-03-29T00:01:30.000Z", + }; expect( hasServerAcknowledgedLocalDispatch({ localDispatch, phase: "ready", - latestTurn: { - ...previousLatestTurn, - turnId: TurnId.make("turn-2"), - requestedAt: "2026-03-29T00:01:00.000Z", - startedAt: "2026-03-29T00:01:01.000Z", - completedAt: "2026-03-29T00:01:30.000Z", - }, - session: { - ...previousSession, - updatedAt: "2026-03-29T00:01:30.000Z", - }, + latestTurn: newerTurn, + session: { ...readySession, updatedAt: newerTurn.completedAt }, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -691,137 +368,43 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); - it("does not clear local dispatch while the session is running a newer turn than latestTurn", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - goal: null, - }); - - expect( - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: "running", - latestTurn: previousLatestTurn, - session: { - ...previousSession, - status: "running", - orchestrationStatus: "running", - activeTurnId: TurnId.make("turn-2"), - updatedAt: "2026-03-29T00:01:00.000Z", - }, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }), - ).toBe(false); - }); - - it("does not clear local dispatch while the session is running but latestTurn has not advanced yet", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - goal: null, - }); + it("waits for the matching running turn before acknowledging", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); + const runningTurn = { + ...completedTurn, + turnId: TurnId.make("turn-2"), + state: "running" as const, + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: null, + }; expect( hasServerAcknowledgedLocalDispatch({ localDispatch, phase: "running", - latestTurn: previousLatestTurn, + latestTurn: runningTurn, session: { - ...previousSession, + ...readySession, status: "running", - orchestrationStatus: "running", - activeTurnId: undefined, - updatedAt: "2026-03-29T00:01:00.000Z", + activeTurnId: TurnId.make("turn-other"), }, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, }), ).toBe(false); - }); - - it("clears local dispatch once the running latestTurn matches the active session turn", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - goal: null, - }); - expect( hasServerAcknowledgedLocalDispatch({ localDispatch, phase: "running", - latestTurn: { - ...previousLatestTurn, - turnId: TurnId.make("turn-2"), - state: "running", - requestedAt: "2026-03-29T00:01:00.000Z", - startedAt: "2026-03-29T00:01:01.000Z", - completedAt: null, - }, + latestTurn: runningTurn, session: { - ...previousSession, + ...readySession, status: "running", - orchestrationStatus: "running", - activeTurnId: TurnId.make("turn-2"), - updatedAt: "2026-03-29T00:01:01.000Z", + activeTurnId: runningTurn.turnId, }, hasPendingApproval: false, hasPendingUserInput: false, @@ -830,44 +413,20 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); - it("clears local dispatch when the session changes without an observed running phase", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - goal: null, - }); - - expect( - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: "ready", - latestTurn: previousLatestTurn, - session: { - ...previousSession, - updatedAt: "2026-03-29T00:00:11.000Z", - }, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }), - ).toBe(true); + it("acknowledges pending user interaction and errors immediately", () => { + const localDispatch = createLocalDispatchSnapshot(makeThread()); + const common = { + localDispatch, + phase: "ready" as const, + latestTurn: null, + session: null, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }; + + expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); + expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); + expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 8e78c0f3a3e..f19d8b05aa0 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -9,10 +9,11 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; +import { type ChatMessage, type SessionPhase, type Thread } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; -import { selectThreadByRef, useStore } from "../store"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentThreadDetails } from "../state/threads"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -30,12 +31,10 @@ export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, fallbackModelSelection: ModelSelection, - error: string | null, ): Thread { return { id: threadId, environmentId: draftThread.environmentId, - codexThreadId: null, projectId: draftThread.projectId, title: "New thread", modelSelection: fallbackModelSelection, @@ -43,13 +42,14 @@ export function buildLocalDraftThread( interactionMode: draftThread.interactionMode, session: null, messages: [], - error, createdAt: draftThread.createdAt, + updatedAt: draftThread.createdAt, archivedAt: null, + deletedAt: null, latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, - turnDiffSummaries: [], + checkpoints: [], activities: [], proposedPlans: [], goal: null, @@ -276,8 +276,8 @@ export function deriveLockedProvider(input: { if (!threadHasStarted(input.thread)) { return null; } - const sessionProvider = input.thread?.session?.provider ?? null; - if (sessionProvider) { + const sessionProvider = input.thread?.session?.providerName ?? null; + if (sessionProvider && isProviderDriverKind(sessionProvider)) { return sessionProvider; } const narrowedThreadProvider = @@ -333,7 +333,8 @@ export async function waitForStartedServerThread( threadRef: ScopedThreadRef, timeoutMs = 1_000, ): Promise { - const getThread = () => selectThreadByRef(useStore.getState(), threadRef); + const threadAtom = environmentThreadDetails.detailAtom(threadRef); + const getThread = () => appAtomRegistry.get(threadAtom); const thread = getThread(); if (threadHasStarted(thread)) { @@ -355,8 +356,8 @@ export async function waitForStartedServerThread( resolve(result); }; - const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(selectThreadByRef(state, threadRef))) { + const unsubscribe = appAtomRegistry.subscribe(threadAtom, (thread) => { + if (!threadHasStarted(thread)) { return; } finish(true); @@ -380,7 +381,7 @@ export interface LocalDispatchSnapshot { latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; latestTurnCompletedAt: string | null; - sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null; + sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; } @@ -397,7 +398,7 @@ export function createLocalDispatchSnapshot( latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, latestTurnCompletedAt: latestTurn?.completedAt ?? null, - sessionOrchestrationStatus: session?.orchestrationStatus ?? null, + sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, }; } @@ -434,8 +435,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { return false; } if ( + session?.activeTurnId !== null && session?.activeTurnId !== undefined && - session.activeTurnId !== null && latestTurn?.turnId !== session.activeTurnId ) { return false; @@ -445,7 +446,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { return ( latestTurnChanged || - input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || + input.localDispatch.sessionStatus !== (session?.status ?? null) || input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index acc44b9e982..ae86671c61b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,7 +21,16 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; -import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { applyClaudePromptEffortPrefix, createModelSelection, @@ -31,17 +40,32 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; -import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; +import { + lazy, + memo, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; -import { useVcsStatus } from "~/lib/vcsStatusState"; -import { usePrimaryEnvironmentId } from "../environments/primary/context"; -import { readEnvironmentApi } from "../environmentApi"; -import { resolveAssetUrl } from "../assets/assetUrls"; +import { + isAtomCommandInterrupted, + mapAtomCommandResult, + settlePromise, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; -import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, parseComposerGoalSlashCommand, @@ -69,8 +93,6 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { selectEnvironmentState, selectProjectsAcrossEnvironments, useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -89,13 +111,13 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useCommandPaletteStore } from "../commandPaletteStore"; +import { isCommandPaletteOpen } from "../commandPaletteContext"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { formatGoalStatusToastDescription, goalStatusToastTitle } from "../goalPresentation"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { - selectActiveRightPanelKindWithUrl, + selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, @@ -103,27 +125,23 @@ import { } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, - selectThreadPreviewState, - usePreviewStateStore, + setActivePreviewTab, + useThreadPreviewState, } from "../previewStateStore"; +import { addBrowserSurface } from "./preview/addBrowserSurface"; +import { closePreviewSession } from "./preview/closePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; -// Lazy: keeps the entire preview component graph (webview host, favicon -// helper, Chromium error icon) out of the web bundle until first open. -const PreviewPanel = lazy(() => - import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), -); -const DiffPanel = lazy(() => import("./DiffPanel")); -const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); -const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); +import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; +import { RightPanelTabs } from "./RightPanelTabs"; +import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; -import { RightPanelTabs } from "./RightPanelTabs"; -import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { cn, randomHex } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -132,20 +150,16 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { useSettings } from "../hooks/useSettings"; +import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime/catalog"; -import { reconnectSavedEnvironment } from "../environments/runtime/service"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -159,8 +173,6 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; import { appendElementContextsToPrompt, type ElementContextDraft, @@ -168,6 +180,28 @@ import { } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; +import { environmentCatalog } from "../connection/catalog"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { + primaryServerAvailableEditorsAtom, + primaryServerKeybindingsAtom, + serverEnvironment, +} from "../state/server"; +import { terminalEnvironment } from "../state/terminal"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { + useProject, + useProjects, + useThread, + useThreadProposedPlans, + useThreadRefs, +} from "../state/entities"; +import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -176,11 +210,12 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { + MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, @@ -195,22 +230,18 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, + reconcileMountedTerminalThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; -import { - useServerAvailableEditors, - useServerConfig, - useServerKeybindings, -} from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { RightPanelSheet } from "./RightPanelSheet"; +import { previewEnvironment } from "../state/preview"; +import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; import { buildVersionMismatchDismissalKey, @@ -218,14 +249,20 @@ import { isVersionMismatchDismissed, resolveServerConfigVersionMismatch, } from "../versionSkew"; +import { useAssetUrls } from "../assets/assetUrls"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; -const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const PreviewPanel = lazy(() => + import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), +); +const DiffPanel = lazy(() => import("./DiffPanel")); +const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", "textarea", @@ -258,7 +295,7 @@ const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; }; type ThreadPlanCatalogEntry = Pick; @@ -298,119 +335,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { return true; } -function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - return useStore( - useMemo(() => { - let previousThreadIds: readonly ThreadId[] = []; - let previousResult: ThreadPlanCatalogEntry[] = []; - let previousEntries = new Map< - ThreadId, - { - shell: object | null; - proposedPlanIds: readonly string[] | undefined; - proposedPlansById: Record | undefined; - entry: ThreadPlanCatalogEntry; - } - >(); - - return (state) => { - const sameThreadIds = - previousThreadIds.length === threadIds.length && - previousThreadIds.every((id, index) => id === threadIds[index]); - const nextEntries = new Map< - ThreadId, - { - shell: object | null; - proposedPlanIds: readonly string[] | undefined; - proposedPlansById: Record | undefined; - entry: ThreadPlanCatalogEntry; - } - >(); - const nextResult: ThreadPlanCatalogEntry[] = []; - let changed = !sameThreadIds; - - for (const threadId of threadIds) { - let shell: object | undefined; - let proposedPlanIds: readonly string[] | undefined; - let proposedPlansById: Record | undefined; - - for (const environmentState of Object.values(state.environmentStateById)) { - const matchedShell = environmentState.threadShellById[threadId]; - if (!matchedShell) { - continue; - } - shell = matchedShell; - proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; - proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as - | Record - | undefined; - break; - } - - if (!shell) { - const previous = previousEntries.get(threadId); - if ( - previous && - previous.shell === null && - previous.proposedPlanIds === undefined && - previous.proposedPlansById === undefined - ) { - nextEntries.set(threadId, previous); - continue; - } - changed = true; - nextEntries.set(threadId, { - shell: null, - proposedPlanIds: undefined, - proposedPlansById: undefined, - entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, - }); - continue; - } - - const previous = previousEntries.get(threadId); - if ( - previous && - previous.shell === shell && - previous.proposedPlanIds === proposedPlanIds && - previous.proposedPlansById === proposedPlansById - ) { - nextEntries.set(threadId, previous); - nextResult.push(previous.entry); - continue; - } - - changed = true; - const proposedPlans = - proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById - ? proposedPlanIds.flatMap((planId) => { - const proposedPlan = proposedPlansById?.[planId]; - return proposedPlan ? [proposedPlan] : []; - }) - : EMPTY_PROPOSED_PLANS; - const entry = { id: threadId, proposedPlans }; - nextEntries.set(threadId, { - shell, - proposedPlanIds, - proposedPlansById, - entry, - }); - nextResult.push(entry); - } - - if (!changed && previousResult.length === nextResult.length) { - return previousResult; - } - - previousThreadIds = threadIds; - previousEntries = nextEntries; - previousResult = nextResult; - return nextResult; - }; - }, [threadIds]), - ); -} - function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -461,21 +385,6 @@ function useLocalDispatchState(input: { }) { const [localDispatch, setLocalDispatch] = useState(null); - const beginLocalDispatch = useCallback( - (options?: { preparingWorktree?: boolean }) => { - const preparingWorktree = Boolean(options?.preparingWorktree); - setLocalDispatch((current) => { - if (current) { - return current.preparingWorktree === preparingWorktree - ? current - : { ...current, preparingWorktree }; - } - return createLocalDispatchSnapshot(input.activeThread, options); - }); - }, - [input.activeThread], - ); - const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); }, []); @@ -501,20 +410,29 @@ function useLocalDispatchState(input: { localDispatch, ], ); - - useEffect(() => { - if (!serverAcknowledgedLocalDispatch) { - return; - } - resetLocalDispatch(); - }, [resetLocalDispatch, serverAcknowledgedLocalDispatch]); + const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; + const beginLocalDispatch = useCallback( + (options?: { preparingWorktree?: boolean }) => { + const preparingWorktree = Boolean(options?.preparingWorktree); + setLocalDispatch((current) => { + const active = serverAcknowledgedLocalDispatch ? null : current; + if (active) { + return active.preparingWorktree === preparingWorktree + ? active + : { ...active, preparingWorktree }; + } + return createLocalDispatchSnapshot(input.activeThread, options); + }); + }, + [input.activeThread, serverAcknowledgedLocalDispatch], + ); return { beginLocalDispatch, resetLocalDispatch, - localDispatchStartedAt: localDispatch?.startedAt ?? null, - isPreparingWorktree: localDispatch?.preparingWorktree ?? false, - isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch, + localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, + isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, + isSendBusy: activeLocalDispatch !== null, }; } @@ -561,7 +479,6 @@ interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; - mode?: "drawer" | "panel"; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; @@ -576,7 +493,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadRef, threadId, visible, - mode = "drawer", launchContext, focusRequestId, splitShortcutLabel, @@ -586,14 +502,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); + const project = useProject(projectRef); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); @@ -652,7 +571,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra cwd: launchContext?.cwd ?? summary.cwd, worktreePath: worktreePathForLaunch, runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: worktreePathForLaunch, }), }); @@ -698,7 +617,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra launchContext?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : null), @@ -708,7 +627,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => project ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : {}, @@ -730,26 +649,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const splitTerminal = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); - void (async () => { - try { - await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }); - } catch { - // Opening failed; the tab is already in the store — user can retry or close it. - } - })(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); }, [ bumpFocusRequestId, cwd, @@ -759,28 +674,30 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeSplitTerminal, threadId, threadRef, + openTerminal, ]); const splitTerminalVertical = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); - void api.terminal - .open({ + void openTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, - }) - .catch(() => undefined); + }, + }); }, [ bumpFocusRequestId, cwd, effectiveWorktreePath, + openTerminal, runtimeEnv, serverOrderedTerminalIds, storeSplitTerminalVertical, @@ -789,26 +706,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ]); const createNewTerminal = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); - void (async () => { - try { - await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }); - } catch { - // Opening failed; the tab is already in the store — user can retry or close it. - } - })(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); }, [ bumpFocusRequestId, cwd, @@ -818,6 +731,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeNewTerminal, threadId, threadRef, + openTerminal, ]); const activateTerminal = useCallback( @@ -830,31 +744,37 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const closeTerminal = useCallback( (terminalId: string) => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) return; - const isFinalTerminal = terminalUiState.terminalIds.length <= 1; const fallbackExitWrite = () => - api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); + writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, data: "exit\n" }, + }); - if ("close" in api.terminal && typeof api.terminal.close === "function") { - void (async () => { - if (isFinalTerminal) { - await api.terminal.clear({ threadId, terminalId }).catch(() => undefined); - } - await api.terminal.close({ + void (async () => { + const closeResult = await closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, deleteHistory: true, - }); - })().catch(() => fallbackExitWrite()); - } else { - void fallbackExitWrite(); - } + }, + }); + if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { + await fallbackExitWrite(); + } + })(); storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef], + [ + bumpFocusRequestId, + storeCloseTerminal, + threadId, + threadRef, + closeTerminalMutation, + writeTerminal, + ], ); const handleAddTerminalContext = useCallback( @@ -872,9 +792,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return ( -
+
; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; @@ -943,41 +862,41 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane newShortcutLabel, closeShortcutLabel, }: PersistentThreadTerminalPanelProps) { - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); + const project = useProject(projectRef); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId: threadRef.threadId, }); - const terminalSummary = + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeSummary = knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; - const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const worktreePath = - launchContext?.worktreePath ?? terminalSummary?.worktreePath ?? threadWorktreePath; + launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; const cwd = useMemo( () => launchContext?.cwd ?? - terminalSummary?.cwd ?? + activeSummary?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath, }) : null), - [launchContext?.cwd, project, terminalSummary?.cwd, worktreePath], + [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], ); const runtimeEnv = useMemo( () => project ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath, }) : {}, @@ -1013,7 +932,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane summary?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, }) : null); @@ -1022,7 +941,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane cwd: terminalCwd, worktreePath: terminalWorktreePath, runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, }), }); @@ -1037,9 +956,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane threadWorktreePath, ]); - if (!project || !cwd) { - return null; - } + if (!project || !cwd) return null; return ( scopedThreadKey(routeThreadRef), [routeThreadRef]); + const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { + reportFailure: false, + }); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); + const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + reportFailure: false, + }); + const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const requestThreadGoal = useAtomCommand(threadEnvironment.requestGoal, { reportFailure: false }); + const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { + reportFailure: false, + }); + const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { + reportFailure: false, + }); + const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, { + reportFailure: false, + }); + const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { + reportFailure: false, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; - const serverThread = useStore( - useMemo( - () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), - [routeKind, routeThreadRef], - ), - ); - const setStoreThreadError = useStore((store) => store.setError); + const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); - const settings = useSettings(); + const settings = useEnvironmentSettings(environmentId); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); - const rawSearch = useSearch({ - strict: false, - select: (params) => parseDiffRouteSearch(params), - }); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -1176,6 +1124,9 @@ function ChatViewContent(props: ChatViewProps) { const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); + const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< + Record + >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( @@ -1207,38 +1158,89 @@ function ChatViewContent(props: ChatViewProps) { const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); + const [ + pendingServerThreadStartFromOriginByThreadId, + setPendingServerThreadStartFromOriginByThreadId, + ] = useState>({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); + const [composerOverlayElement, setComposerOverlayElement] = useState(null); + const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalUiOpenByThreadRef = useRef>({}); + useLayoutEffect(() => { + if (!composerOverlayElement) return; + + const updateHeight = () => { + const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); + setComposerOverlayHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + }; + + updateHeight(); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(updateHeight); + observer.observe(composerOverlayElement); + return () => observer.disconnect(); + }, [composerOverlayElement]); + const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); + const openTerminalThreadKeys = useTerminalUiStateStore( + useShallow((state) => + Object.entries(state.terminalUiStateByThreadKey).flatMap( + ([nextThreadKey, nextTerminalUiState]) => + nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], + ), + ), + ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); + const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); + const serverThreadRefs = useThreadRefs(); + const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftThreadKeys = useMemo( + () => + Object.values(draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ), + [draftThreadsByThreadKey], + ); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); + const mountedTerminalThreadRefs = useMemo( + () => + mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; + }), + [mountedTerminalThreadKeys], + ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const fallbackDraftProject = useStore( - useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), - ); + const fallbackDraftProject = useProject(fallbackDraftProjectRef); const localDraftError = routeKind === "server" && serverThread ? null : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); + const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null; const localDraftThread = useMemo( () => draftThread @@ -1249,19 +1251,20 @@ function ChatViewContent(props: ChatViewProps) { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }, - localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], + [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], ); - const isServerThread = routeKind === "server" && serverThread !== undefined; + const isServerThread = routeKind === "server" && serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; + const threadError = isServerThread + ? (localServerError ?? serverThread?.session?.lastError ?? null) + : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, @@ -1288,35 +1291,41 @@ function ChatViewContent(props: ChatViewProps) { [activeServerOrderedTerminalIds, terminalUiState.terminalIds], ); const activeTerminalLabelsById = useMemo(() => { - const next = new Map(); + const labels = new Map(); for (const session of activeThreadKnownSessions) { - next.set( + labels.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } - return next; + return labels; }, [activeThreadKnownSessions]); - const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; - const activeRightPanelKind = useRightPanelStore((store) => - selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), + const [timelineAnchor, setTimelineAnchor] = useState<{ + readonly threadKey: string | null; + readonly messageId: MessageId | null; + }>({ threadKey: activeThreadKey, messageId: null }); + if (timelineAnchor.threadKey !== activeThreadKey) { + setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); + } + const timelineAnchorMessageId = timelineAnchor.messageId; + const activeRightPanelKind = useRightPanelStore((state) => + selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); - const rightPanelState = useRightPanelStore((store) => - selectThreadRightPanelState(store.byThreadKey, activeThreadRef), + const diffOpen = activeRightPanelKind === "diff"; + const rightPanelState = useRightPanelStore((state) => + selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); - const activeRightPanelSurface = useRightPanelStore((store) => - selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), + const activeRightPanelSurface = useRightPanelStore((state) => + selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; - const activePreviewState = usePreviewStateStore((state) => - selectThreadPreviewState(state.byThreadKey, activeThreadRef), - ); + const activePreviewState = useThreadPreviewState(activeThreadRef); const panelTerminalIds = useMemo( () => new Set( @@ -1326,37 +1335,11 @@ function ChatViewContent(props: ChatViewProps) { ), [rightPanelState.surfaces], ); - const drawerServerOrderedTerminalIds = useMemo( - () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)), - [activeServerOrderedTerminalIds, panelTerminalIds], - ); - useEffect(() => { - if (!activeThreadRef) { - return; - } - if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient( - drawerServerOrderedTerminalIds, - terminalUiState.terminalIds, - ) - ) { - return; - } - reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds); - }, [ - activeThreadRef, - drawerServerOrderedTerminalIds, - reconcileTerminalIds, - terminalUiState.terminalIds, - ]); - const planSidebarOpen = activeRightPanelKind === "plan"; const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; + const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = - rightPanelOpen && !shouldUsePlanSidebarSheet && maximizedRightPanelThreadKey === routeThreadKey; + canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { @@ -1366,33 +1349,62 @@ function ChatViewContent(props: ChatViewProps) { .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); }, [activePreviewState.sessions, activeThreadRef]); - useEffect(() => { - if (!activeThreadRef || !diffOpen) return; - useRightPanelStore.getState().open(activeThreadRef, "diff"); - }, [activeThreadRef, diffOpen]); + const planSidebarOpen = activeRightPanelKind === "plan"; + + const existingOpenTerminalThreadKeys = useMemo(() => { + const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); + }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const threadPlanCatalog = useThreadPlanCatalog( - useMemo(() => { - const threadIds: ThreadId[] = []; - if (activeThread?.id) { - threadIds.push(activeThread.id); - } - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (sourceThreadId && sourceThreadId !== activeThread?.id) { - threadIds.push(sourceThreadId); - } - return threadIds; - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), - ); + const sourcePlanThreadRef = useMemo(() => { + const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; + if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { + return null; + } + return scopeThreadRef(activeThread.environmentId, sourceThreadId); + }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); + const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); + const threadPlanCatalog = useMemo(() => { + if (!activeThread) { + return []; + } + const entries: ThreadPlanCatalogEntry[] = [ + { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, + ]; + if (sourcePlanThreadRef) { + entries.push({ + id: sourcePlanThreadRef.threadId, + proposedPlans: sourceThreadProposedPlans, + }); + } + return entries; + }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); + useEffect(() => { + setMountedTerminalThreadKeys((currentThreadIds) => { + const nextThreadIds = reconcileMountedTerminalThreadIds({ + currentThreadIds, + openThreadIds: existingOpenTerminalThreadKeys, + activeThreadId: activeThreadKey, + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); + return currentThreadIds.length === nextThreadIds.length && + currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) + ? currentThreadIds + : nextThreadIds; + }); + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; - const activeProject = useStore( - useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), + const activeProject = useProject(activeProjectRef); + const activeEnvironmentShell = useEnvironmentQuery( + activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); + const activeEnvironmentBootstrapComplete = activeEnvironmentShell.data?.snapshot._tag === "Some"; const activeProjectKey = activeProject - ? `${activeProject.environmentId}:${activeProject.cwd}` + ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> @@ -1407,30 +1419,17 @@ function ChatViewContent(props: ChatViewProps) { const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; const surfaceId = `file:${relativePath}`; if (current.has(surfaceId) === pending) return currentByProject; - const next = new Set(current); - if (pending) { - next.add(surfaceId); - } else { - next.delete(surfaceId); - } - + if (pending) next.add(surfaceId); + else next.delete(surfaceId); const nextByProject = new Map(currentByProject); - if (next.size === 0) { - nextByProject.delete(activeProjectKey); - } else { - nextByProject.set(activeProjectKey, next); - } + if (next.size === 0) nextByProject.delete(activeProjectKey); + else nextByProject.set(activeProjectKey, next); return nextByProject; }); }, [activeProjectKey], ); - const activeEnvironmentBootstrapComplete = useStore((state) => - activeThread - ? selectEnvironmentState(state, activeThread.environmentId).bootstrapComplete - : false, - ); const configuredPreviewUrls = useMemo( () => getConfiguredPreviewUrls(activeProject?.scripts), [activeProject?.scripts], @@ -1438,83 +1437,35 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; - useRightPanelStore - .getState() - .reconcileFileSurfaces(activeThreadRef, activeProject !== undefined); + useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - useEffect(() => { - if (routeKind !== "server") { - return; - } - return retainThreadDetailSubscription(environmentId, threadId); - }, [environmentId, routeKind, threadId]); - // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. - const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); - const activeSavedEnvironmentRecord = - activeThread && activeThread.environmentId !== primaryEnvironmentId - ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null) - : null; - const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord - ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null) - : null; - const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord - ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected") - : "connected"; + const allProjects = useProjects(); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + const activeEnvironment = + activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); + const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = - activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected"; - const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null; - const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord - ? resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: activeSavedEnvironmentRecord.environmentId, - runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null, - savedLabel: activeSavedEnvironmentRecord.label, - }) - : null; + activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; + const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { - if ( - !activeEnvironmentUnavailable || - !activeEnvironmentUnavailableLabel || - !activeSavedEnvironmentId - ) { + if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { return null; } return { - environmentId: activeSavedEnvironmentId, + environmentId: activeEnvironment.environmentId, label: activeEnvironmentUnavailableLabel, - connectionState: - activeSavedEnvironmentConnectionState === "connecting" || - activeSavedEnvironmentConnectionState === "error" - ? activeSavedEnvironmentConnectionState - : "disconnected", + connection: activeEnvironment.connection, }; - }, [ - activeEnvironmentUnavailable, - activeEnvironmentUnavailableLabel, - activeSavedEnvironmentConnectionState, - activeSavedEnvironmentId, - ]); - const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState( - null, - ); + }, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]); const handleReconnectActiveEnvironment = useCallback( - async (environmentId: EnvironmentId, label: string) => { - setReconnectingEnvironmentId(environmentId); - try { - await reconnectSavedEnvironment(environmentId); - toastManager.add({ - type: "success", - title: "Environment reconnected", - description: `${label} is ready.`, - }); - } catch (error) { + async (environmentId: EnvironmentId) => { + const result = await retryEnvironment(environmentId); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1522,13 +1473,11 @@ function ChatViewContent(props: ChatViewProps) { description: error instanceof Error ? error.message : "Failed to reconnect.", }), ); - } finally { - setReconnectingEnvironmentId(null); } }, - [], + [retryEnvironment], ); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -1546,14 +1495,7 @@ function ChatViewContent(props: ChatViewProps) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; - const savedRecord = savedEnvironmentRegistry[p.environmentId]; - const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; - const label = resolveEnvironmentOptionLabel({ - isPrimary, - environmentId: p.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? null, - savedLabel: savedRecord?.label ?? null, - }); + const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; envs.push({ environmentId: p.environmentId, projectId: p.id, @@ -1567,14 +1509,7 @@ function ChatViewContent(props: ChatViewProps) { return a.label.localeCompare(b.label); }); return envs; - }, [ - activeProject, - allProjects, - projectGroupingSettings, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const openPullRequestDialog = useCallback( @@ -1684,24 +1619,21 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!serverThread?.id) return; - if (!latestTurnSettled) return; - if (!activeLatestTurn?.completedAt) return; - const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); - if (Number.isNaN(turnCompletedAt)) return; + const threadUpdatedAt = Date.parse(serverThread.updatedAt); + if (Number.isNaN(threadUpdatedAt)) return; const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; + if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - activeLatestTurn.completedAt, + serverThread.updatedAt, ); }, [ - activeLatestTurn?.completedAt, activeThreadLastVisitedAt, - latestTurnSettled, markThreadVisited, serverThread?.environmentId, serverThread?.id, + serverThread?.updatedAt, ]); const selectedProviderByThreadId = composerActiveProvider ?? null; @@ -1714,17 +1646,11 @@ function ChatViewContent(props: ChatViewProps) { selectedProvider: selectedProviderByThreadId, threadProvider, }); - const primaryServerConfig = useServerConfig(); - const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => - activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, - ); - // Use the server config for the thread's environment. For the primary - // environment fall back to the global atom; for remote environments use - // the runtime state stored by the environment manager. - const serverConfig = - primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId - ? primaryServerConfig - : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); + // Once a thread selects an environment, never substitute the primary + // environment's config while the selected environment is still loading. + const serverConfig = activeThread + ? (activeEnvironment?.serverConfig ?? null) + : (primaryEnvironment?.serverConfig ?? null); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -1738,65 +1664,37 @@ function ChatViewContent(props: ChatViewProps) { isVersionMismatchDismissed(versionMismatchDismissKey); const showVersionMismatchBanner = versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0; - const versionMismatchServerLabel = useMemo(() => { - if (!hasMultipleRegisteredEnvironments || !activeThread) { - return "server"; - } - - const isPrimary = activeThread.environmentId === primaryEnvironmentId; - const savedRecord = savedEnvironmentRegistry[activeThread.environmentId]; - const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId]; - return `${resolveEnvironmentOptionLabel({ - isPrimary, - environmentId: activeThread.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null, - savedLabel: savedRecord?.label ?? null, - })} server`; - }, [ - activeThread, - hasMultipleRegisteredEnvironments, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - serverConfig?.environment.label, - ]); + const hasMultipleRegisteredEnvironments = environments.length > 1; + const versionMismatchServerLabel = + hasMultipleRegisteredEnvironments && activeThread + ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` + : "server"; const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { + const connection = activeEnvironmentUnavailableState.connection; + const isReconnecting = + connection.phase === "connecting" || connection.phase === "reconnecting"; items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: - activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning", + variant: connection.phase === "error" ? "error" : "warning", icon: , - title: ( - <> - {activeEnvironmentUnavailableState.label} is{" "} - {activeEnvironmentUnavailableState.connectionState === "connecting" - ? "connecting" - : "disconnected"} - - ), - description: "Reconnect this environment before sending messages or running actions.", + title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusText(connection)}`, + description: + connection.error ?? + "Reconnect this environment before sending messages or running actions.", actions: ( <>
); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2f5bd282c08..ac646307e12 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -14,7 +14,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: LOCAL_ENVIRONMENT_ID, - codexThreadId: null, projectId: PROJECT_ID, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, @@ -23,14 +22,14 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], - error: null, createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, + deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, - turnDiffSummaries: [], + checkpoints: [], activities: [], goal: null, ...overrides, diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 982950be5e5..ab53adbefb1 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -100,9 +100,9 @@ export function buildProjectActionItems(input: { return input.projects.map((project) => ({ kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [project.name, project.cwd], - title: project.name, - description: project.cwd, + searchTerms: [project.title, project.workspaceRoot], + title: project.title, + description: project.workspaceRoot, icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { @@ -115,7 +115,7 @@ export type BuildThreadActionItemsThread = Pick< SidebarThreadSummary, "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" > & { - updatedAt?: string | undefined; + updatedAt: string; latestUserMessageAt?: string | null; }; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ebfc260ca43..a11d6c4cb07 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,11 @@ "use client"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_MODEL, type EnvironmentId, @@ -11,7 +16,6 @@ import { type SourceControlProviderKind, type SourceControlRepositoryInfo, } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { @@ -32,26 +36,25 @@ import { useEffect, useLayoutEffect, useMemo, + useReducer, useRef, useState, type KeyboardEvent, type ReactNode, } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { useCommandPaletteStore } from "../commandPaletteStore"; -import { readEnvironmentApi } from "../environmentApi"; -import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { useSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; -import { - getSourceControlDiscoverySnapshot, - refreshSourceControlDiscovery, -} from "../lib/sourceControlDiscoveryState"; +import { filesystemEnvironment } from "../state/filesystem"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, @@ -73,12 +76,7 @@ import { } from "../lib/projectPaths"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; -import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils"; -import { - selectProjectsAcrossEnvironments, - selectSidebarThreadsAcrossEnvironments, - useStore, -} from "../store"; +import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { @@ -102,7 +100,7 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { useServerKeybindings } from "../rpc/serverState"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand } from "../keybindings"; import { Command, @@ -120,7 +118,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand import type { ChatComposerHandle } from "./chat/ChatComposer"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; -const BROWSE_STALE_TIME_MS = 30_000; function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { @@ -326,11 +323,50 @@ function errorMessage(error: unknown): string { return "An error occurred."; } +interface CommandPaletteOpenIntent { + readonly kind: "add-project"; +} + +interface CommandPaletteUiState { + readonly open: boolean; + readonly openIntent: CommandPaletteOpenIntent | null; +} + +type CommandPaletteUiAction = + | { readonly _tag: "SetOpen"; readonly open: boolean } + | { readonly _tag: "Toggle" } + | { readonly _tag: "OpenAddProject" } + | { readonly _tag: "ClearOpenIntent" }; + +function reduceCommandPaletteUiState( + state: CommandPaletteUiState, + action: CommandPaletteUiAction, +): CommandPaletteUiState { + switch (action._tag) { + case "SetOpen": + return { + open: action.open, + openIntent: action.open ? state.openIntent : null, + }; + case "Toggle": + return { open: !state.open, openIntent: null }; + case "OpenAddProject": + return { open: true, openIntent: { kind: "add-project" } }; + case "ClearOpenIntent": + return state.openIntent ? { ...state, openIntent: null } : state; + } +} + export function CommandPalette({ children }: { children: ReactNode }) { - const open = useCommandPaletteStore((store) => store.open); - const setOpen = useCommandPaletteStore((store) => store.setOpen); - const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); - const keybindings = useServerKeybindings(); + const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { + open: false, + openIntent: null, + }); + const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); + const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); + const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); + const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -364,49 +400,70 @@ export function CommandPalette({ children }: { children: ReactNode }) { }, [keybindings, terminalOpen, toggleOpen]); return ( - - - {children} - - - + + + + {children} + + + + ); } -function CommandPaletteDialog() { - const open = useCommandPaletteStore((store) => store.open); - const setOpen = useCommandPaletteStore((store) => store.setOpen); - - useEffect(() => { - return () => { - setOpen(false); - }; - }, [setOpen]); - - if (!open) { +function CommandPaletteDialog(props: { + readonly open: boolean; + readonly openIntent: CommandPaletteOpenIntent | null; + readonly setOpen: (open: boolean) => void; + readonly clearOpenIntent: () => void; +}) { + if (!props.open) { return null; } - return ; + return ( + + ); } -function OpenCommandPaletteDialog() { +function OpenCommandPaletteDialog(props: { + readonly openIntent: CommandPaletteOpenIntent | null; + readonly setOpen: (open: boolean) => void; + readonly clearOpenIntent: () => void; +}) { const navigate = useNavigate(); - const setOpen = useCommandPaletteStore((store) => store.setOpen); - const openIntent = useCommandPaletteStore((store) => store.openIntent); - const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent); + const { clearOpenIntent, openIntent, setOpen } = props; const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); - const queryClient = useQueryClient(); const [highlightedItemValue, setHighlightedItemValue] = useState(null); - const settings = useSettings(); + const clientSettings = useClientSettings(); + const createProject = useAtomCommand(projectEnvironment.create, { + reportFailure: false, + }); + const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { + reportFailure: false, + }); + const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { + reportFailure: false, + }); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); - const keybindings = useServerKeybindings(); + const projects = useProjects(); + const threads = useThreadShells(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -417,45 +474,21 @@ function OpenCommandPaletteDialog() { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const addProjectEnvironmentOptions = useMemo(() => { - const options: AddProjectEnvironmentOption[] = []; - const seenEnvironmentIds = new Set(); - - if (primaryEnvironmentId) { - seenEnvironmentIds.add(primaryEnvironmentId); - options.push({ - environmentId: primaryEnvironmentId, - label: resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: primaryEnvironmentId, - runtimeLabel: primaryEnvironmentLabel, - }), - isPrimary: true, - }); - } - - for (const record of Object.values(savedEnvironmentRegistry)) { - if (seenEnvironmentIds.has(record.environmentId)) { - continue; - } - - const runtimeState = savedEnvironmentRuntimeById[record.environmentId]; - options.push({ - environmentId: record.environmentId, + const options = environments.map((environment): AddProjectEnvironmentOption => { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + return { + environmentId: environment.environmentId, label: resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: record.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? null, - savedLabel: record.label, + isPrimary, + environmentId: environment.environmentId, + runtimeLabel: environment.label, }), - isPrimary: false, - }); - } + isPrimary, + }; + }); options.sort((left, right) => { if (left.isPrimary !== right.isPrimary) { @@ -465,26 +498,22 @@ function OpenCommandPaletteDialog() { }); return options; - }, [ - primaryEnvironmentId, - primaryEnvironmentLabel, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [environments]); const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; - const browseEnvironmentPlatform = useMemo(() => { - const os = - browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId - ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null) - : browseEnvironmentId - ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ?? - savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform - .os ?? - null) - : null; - return getEnvironmentBrowsePlatform(os); - }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); + const browseEnvironment = + environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; + const sourceControlDiscovery = useEnvironmentQuery( + browseEnvironmentId === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: browseEnvironmentId, + input: {}, + }), + ); + const browseEnvironmentPlatform = getEnvironmentBrowsePlatform( + browseEnvironment?.serverConfig?.environment.platform.os, + ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; const isBrowsing = @@ -492,27 +521,26 @@ function OpenCommandPaletteDialog() { const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { - const environmentSettings = - environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId - ? settings - : environmentId - ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings - : null; + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + const environmentSettings = environment?.serverConfig?.settings ?? null; const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; if (baseDirectory.length === 0) { return "~/"; } return ensureBrowseDirectoryPath(baseDirectory); }, - [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], + [environments], ); const projectCwdById = useMemo( - () => new Map(projects.map((project) => [project.id, project.cwd])), + () => + new Map(projects.map((project) => [project.id, project.workspaceRoot])), [projects], ); const projectTitleById = useMemo( - () => new Map(projects.map((project) => [project.id, project.name])), + () => new Map(projects.map((project) => [project.id, project.title])), [projects], ); @@ -532,75 +560,34 @@ function OpenCommandPaletteDialog() { const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; const browseFilterQuery = isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; - - const fetchBrowseResult = useCallback( - async (partialPath: string): Promise => { - if (!browseEnvironmentId) return null; - const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return null; - return api.filesystem.browse({ - partialPath, - ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), - }); - }, - [browseEnvironmentId, currentProjectCwdForBrowse], - ); - - const { data: browseResult, isPending: isBrowsePending } = useQuery({ - queryKey: [ - "filesystemBrowse", - browseEnvironmentId, - browseDirectoryPath, - currentProjectCwdForBrowse, - ], - queryFn: () => fetchBrowseResult(browseDirectoryPath), - staleTime: BROWSE_STALE_TIME_MS, - enabled: - isBrowsing && + const browseQuery = useEnvironmentQuery( + isBrowsing && browseDirectoryPath.length > 0 && browseEnvironmentId !== null && - !relativePathNeedsActiveProject, - }); + !relativePathNeedsActiveProject + ? filesystemEnvironment.browse({ + environmentId: browseEnvironmentId, + input: { + partialPath: browseDirectoryPath, + ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), + }, + }) + : null, + ); + const browseResult = browseQuery.data; + const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), [browseEntries, browseFilterQuery, highlightedItemValue], ); - const prefetchBrowsePath = useCallback( - (partialPath: string) => { - void queryClient.prefetchQuery({ - queryKey: [ - "filesystemBrowse", - browseEnvironmentId, - partialPath, - currentProjectCwdForBrowse, - ], - queryFn: () => fetchBrowseResult(partialPath), - staleTime: BROWSE_STALE_TIME_MS, - }); - }, - [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], - ); - - // Prefetch only the parent (for back-navigation). Prefetching the - // highlighted child on every arrow-key press triggers a macOS TCC prompt - // whenever the highlighted entry is a permission-gated home dir (Music, - // Documents, Downloads, Desktop, etc.), so we wait for explicit navigation. - useEffect(() => { - if (!isBrowsing || filteredBrowseEntries.length === 0) return; - - if (canNavigateUp(query)) { - prefetchBrowsePath(getBrowseParentPath(query)!); - } - }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]); - const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === project.environmentId), project.id, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, ); if (latestThread) { await navigate({ @@ -612,17 +599,9 @@ function OpenCommandPaletteDialog() { return; } - await handleNewThread(scopeProjectRef(project.environmentId, project.id), { - envMode: settings.defaultThreadEnvMode, - }); + await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }, - [ - handleNewThread, - navigate, - settings.defaultThreadEnvMode, - settings.sidebarThreadSortOrder, - threads, - ], + [handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads], ); const projectSearchItems = useMemo( @@ -633,7 +612,7 @@ function OpenCommandPaletteDialog() { icon: (project) => ( ), @@ -651,7 +630,7 @@ function OpenCommandPaletteDialog() { icon: (project) => ( ), @@ -659,23 +638,15 @@ function OpenCommandPaletteDialog() { await startNewThreadInProjectFromContext( { activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, }, scopeProjectRef(project.environmentId, project.id), ); }, }), - [ - activeDraftThread, - activeThread, - defaultProjectRef, - handleNewThread, - projects, - settings.defaultThreadEnvMode, - ], + [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], ); const allThreadItems = useMemo( @@ -684,7 +655,7 @@ function OpenCommandPaletteDialog() { threads, ...(activeThreadId ? { activeThreadId } : {}), projectTitleById, - sortOrder: settings.sidebarThreadSortOrder, + sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , renderTrailingContent: (thread) => , @@ -695,7 +666,7 @@ function OpenCommandPaletteDialog() { }); }, }), - [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads], + [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -867,40 +838,17 @@ function OpenCommandPaletteDialog() { (environmentId: EnvironmentId): void => { setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); - const target = { environmentId }; - const initialDiscovery = getSourceControlDiscoverySnapshot(target).data; pushPaletteView({ addonIcon: , groups: buildAddProjectSourceGroups( environmentId, - buildAddProjectRemoteSourceReadiness(initialDiscovery), + buildAddProjectRemoteSourceReadiness( + browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null, + ), ), }); - - if (initialDiscovery) { - return; - } - - void refreshSourceControlDiscovery(target).then((discovery) => { - setViewStack((previousViews) => { - const currentTopView = previousViews.at(-1); - if (currentTopView?.groups[0]?.value !== `sources:${environmentId}`) { - return previousViews; - } - return [ - ...previousViews.slice(0, -1), - { - addonIcon: , - groups: buildAddProjectSourceGroups( - environmentId, - buildAddProjectRemoteSourceReadiness(discovery), - ), - }, - ]; - }); - }); }, - [buildAddProjectSourceGroups], + [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], ); const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( @@ -988,9 +936,8 @@ function OpenCommandPaletteDialog() { run: async () => { await startNewThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, }); }, @@ -1049,7 +996,17 @@ function OpenCommandPaletteDialog() { }); const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); - const activeGroups = currentView ? currentView.groups : rootGroups; + const sourceSelectionViewValue = + addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; + const activeGroups = + addProjectEnvironmentId !== null && + currentView !== null && + currentView.groups[0]?.value === sourceSelectionViewValue + ? buildAddProjectSourceGroups( + addProjectEnvironmentId, + buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), + ) + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, @@ -1062,8 +1019,6 @@ function OpenCommandPaletteDialog() { const handleAddProject = useCallback( async (rawCwd: string) => { if (!browseEnvironmentId) return; - const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return; if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { toastManager.add( @@ -1098,7 +1053,7 @@ function OpenCommandPaletteDialog() { const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, ); if (latestThread) { await navigate({ @@ -1108,19 +1063,29 @@ function OpenCommandPaletteDialog() { ), }); } else { - await handleNewThread(scopeProjectRef(existing.environmentId, existing.id), { - envMode: settings.defaultThreadEnvMode, - }).catch(() => undefined); + const navigationResult = await settlePromise(() => + handleNewThread(scopeProjectRef(existing.environmentId, existing.id)), + ); + if (navigationResult._tag === "Failure") { + const error = squashAtomCommandFailure(navigationResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } } setOpen(false); return; } - try { - const projectId = newProjectId(); - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), + const projectId = newProjectId(); + const createResult = await createProject({ + environmentId: browseEnvironmentId, + input: { projectId, title: inferProjectTitleFromPath(cwd), workspaceRoot: cwd, @@ -1129,13 +1094,27 @@ function OpenCommandPaletteDialog() { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }, - createdAt: new Date().toISOString(), - }); - await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { - envMode: settings.defaultThreadEnvMode, - }).catch(() => undefined); - setOpen(false); - } catch (error) { + }, + }); + if (createResult._tag === "Failure") { + if (!isAtomCommandInterrupted(createResult)) { + const error = squashAtomCommandFailure(createResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + + const navigationResult = await settlePromise(() => + handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)), + ); + if (navigationResult._tag === "Failure") { + const error = squashAtomCommandFailure(navigationResult); toastManager.add( stackedThreadToast({ type: "error", @@ -1143,18 +1122,20 @@ function OpenCommandPaletteDialog() { description: error instanceof Error ? error.message : "An error occurred.", }), ); + return; } + setOpen(false); }, [ browseEnvironmentId, browseEnvironmentPlatform, currentProjectCwdForBrowse, handleNewThread, + createProject, navigate, projects, setOpen, - settings.defaultThreadEnvMode, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, threads, ], ); @@ -1168,18 +1149,6 @@ function OpenCommandPaletteDialog() { return; } - const api = readEnvironmentApi(addProjectCloneFlow.environmentId); - if (!api) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to clone project", - description: "Environment API is not available.", - }), - ); - return; - } - if (addProjectCloneFlow.step === "repository") { const rawRepository = query.trim(); if (rawRepository.length === 0 || isRemoteProjectLookingUp) { @@ -1204,34 +1173,39 @@ function OpenCommandPaletteDialog() { } setIsRemoteProjectLookingUp(true); - try { - const repository = await api.sourceControl.lookupRepository({ + const lookupResult = await lookupRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { provider, repository: rawRepository, - }); - const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); - setAddProjectCloneFlow({ - step: "confirm", - environmentId: addProjectCloneFlow.environmentId, - source: addProjectCloneFlow.source, - repositoryInput: rawRepository, - repository, - remoteUrl: repository.sshUrl, - }); - setHighlightedItemValue(null); - setQuery(destinationPath); - setBrowseGeneration((generation) => generation + 1); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Repository lookup failed", - description: errorMessage(error), - }), - ); - } finally { - setIsRemoteProjectLookingUp(false); + }, + }); + setIsRemoteProjectLookingUp(false); + if (lookupResult._tag === "Failure") { + if (!isAtomCommandInterrupted(lookupResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Repository lookup failed", + description: errorMessage(squashAtomCommandFailure(lookupResult)), + }), + ); + } + return; } + const repository = lookupResult.value; + const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: rawRepository, + repository, + remoteUrl: repository.sshUrl, + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); return; } @@ -1271,23 +1245,27 @@ function OpenCommandPaletteDialog() { } setIsRemoteProjectCloning(true); - try { - const result = await api.sourceControl.cloneRepository({ + const cloneResult = await cloneRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { remoteUrl: addProjectCloneFlow.remoteUrl, destinationPath, - }); - await handleAddProject(result.cwd); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Clone failed", - description: errorMessage(error), - }), - ); - } finally { - setIsRemoteProjectCloning(false); + }, + }); + setIsRemoteProjectCloning(false); + if (cloneResult._tag === "Failure") { + if (!isAtomCommandInterrupted(cloneResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: errorMessage(squashAtomCommandFailure(cloneResult)), + }), + ); + } + return; } + await handleAddProject(cloneResult.value.cwd); } function browseTo(name: string): void { @@ -1515,6 +1493,7 @@ function OpenCommandPaletteDialog() { { composerHandleRef?.current?.focusAtEnd(); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 080fa291e7e..cbcd36ce05e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,32 +1,29 @@ -import { Virtualizer } from "@pierre/diffs/react"; -import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { useParams } from "@tanstack/react-router"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { + ArrowRightIcon, + CheckIcon, ChevronDownIcon, - ChevronLeftIcon, ChevronRightIcon, Columns2Icon, PilcrowIcon, Rows3Icon, + SearchIcon, TextWrapIcon, } from "lucide-react"; -import { - type WheelEvent as ReactWheelEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { type DraftId } from "../composerDraftStore"; +import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; -import { openDiffFilePrimaryAction } from "../diffFileActions"; -import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, @@ -36,18 +33,49 @@ import { resolveFileDiffPath, } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { selectProjectByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; -import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; -import { useSettings } from "../hooks/useSettings"; +import { useProject, useThread } from "../state/entities"; +import { resolveThreadRouteRef } from "../threadRoutes"; +import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; +import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; +import { Switch } from "./ui/switch"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "./ui/combobox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { reviewEnvironment } from "../state/review"; +import { vcsEnvironment } from "../state/vcs"; +import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; +const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; + +interface CollapsedDiffFilesState { + readonly scopeKey: string | null; + readonly fileKeys: ReadonlySet; +} + +const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); const DIFF_PANEL_UNSAFE_CSS = ` [data-diffs-header], @@ -155,44 +183,52 @@ interface DiffPanelProps { export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) { - const navigate = useNavigate(); const { resolvedTheme } = useTheme(); - const settings = useSettings(); + const settings = useClientSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); - const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); + const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); - const [collapsedDiffFileKeys, setCollapsedDiffFileKeys] = useState>( - () => new Set(), - ); - const patchViewportRef = useRef(null); - const turnStripRef = useRef(null); - const previousDiffOpenRef = useRef(false); - const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); - const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); + const [baseRefQuery, setBaseRefQuery] = useState(""); + const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ + scopeKey: null, + fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, + })); + const codeViewRef = useRef(null); + const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); - const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadRef?.threadId ?? null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), + const diffSelection = useDiffPanelStore((state) => + selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); + const activeThreadId = routeThreadRef?.threadId ?? null; + const activeThread = useThread(routeThreadRef); const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((store) => + const activeProject = useProject( activeThread && activeProjectId - ? selectProjectByRef(store, { + ? { environmentId: activeThread.environmentId, projectId: activeProjectId, + } + : null, + ); + const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; + const serverConfig = useAtomValue( + serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), + ); + const openInPreferredEditor = useOpenInPreferredEditor( + activeThread?.environmentId ?? null, + serverConfig?.availableEditors ?? [], + ); + const gitStatusQuery = useEnvironmentQuery( + activeThread !== null && activeThread !== undefined && activeCwd != null + ? vcsEnvironment.status({ + environmentId: activeThread.environmentId, + input: { cwd: activeCwd }, }) - : undefined, + : null, ); - const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useVcsStatus({ - environmentId: activeThread?.environmentId ?? null, - cwd: activeCwd ?? null, - }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -211,8 +247,20 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); - const selectedTurnId = diffSearch.diffTurnId ?? null; - const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; + useEffect(() => { + if (!routeThreadRef || diffSelection.kind !== "turn") return; + useDiffPanelStore.getState().reconcileTurnSelection( + routeThreadRef, + orderedTurnDiffSummaries.map((summary) => summary.turnId), + ); + }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); + + const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null; + const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; + const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; + const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; + const selectedFileRevealRequestId = + diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; const selectedTurn = selectedTurnId === null ? undefined @@ -221,10 +269,28 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); - const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation"; + const latestTurn = orderedTurnDiffSummaries[0]; + const selectedScopeLabel = + selectedTurnId === null + ? selectedGitScope === "unstaged" + ? "Working tree" + : "Branch changes" + : selectedTurn?.turnId === latestTurn?.turnId + ? "Latest turn" + : `Turn ${selectedCheckpointTurnCount ?? "?"}`; + const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope; + const collapseScopeKey = routeThreadRef + ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` + : null; + const collapsedDiffFileKeys = + collapsedDiffFiles.scopeKey === collapseScopeKey + ? collapsedDiffFiles.fileKeys + : EMPTY_COLLAPSED_DIFF_FILE_KEYS; const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : "All turns"; + : selectedGitScope === "unstaged" + ? "Working tree" + : "Branch changes"; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -235,62 +301,116 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff : null, [selectedCheckpointTurnCount], ); - const conversationCheckpointTurnCount = useMemo(() => { - const turnCounts: Array = []; - for (const summary of orderedTurnDiffSummaries) { - const value = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof value === "number") { - turnCounts.push(value); - } - } - if (turnCounts.length === 0) { - return undefined; - } - const latest = Math.max(...turnCounts); - return latest > 0 ? latest : undefined; - }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); - const conversationCheckpointRange = useMemo( - () => - !selectedTurn && typeof conversationCheckpointTurnCount === "number" - ? { - fromTurnCount: 0, - toTurnCount: conversationCheckpointTurnCount, - } - : null, - [conversationCheckpointTurnCount, selectedTurn], - ); - const activeCheckpointRange = selectedTurn - ? selectedCheckpointRange - : conversationCheckpointRange; - const conversationCacheScope = useMemo(() => { - if (selectedTurn || orderedTurnDiffSummaries.length === 0) { - return null; - } - return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; - }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiff = useCheckpointDiff( { environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, - fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, - toTurnCount: activeCheckpointRange?.toTurnCount ?? null, + fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null, + toTurnCount: selectedCheckpointRange?.toTurnCount ?? null, ignoreWhitespace: diffIgnoreWhitespace, - cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, + cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null, }, - { enabled: isGitRepo }, + { enabled: isGitRepo && selectedTurn !== undefined }, ); - const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiff.data?.diff : undefined; - const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiff.data?.diff; - const isLoadingCheckpointDiff = activeCheckpointDiff.isPending; - const checkpointDiffError = activeCheckpointDiff.error; - - const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; + const primaryBranchDiffPreview = useEnvironmentQuery( + selectedTurnId === null && activeThread && activeCwd + ? reviewEnvironment.diffPreview({ + environmentId: activeThread.environmentId, + input: { + cwd: activeCwd, + ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ignoreWhitespace: diffIgnoreWhitespace, + }, + }) + : null, + ); + const shouldRetryBranchDiffAtEnvironmentCwd = + selectedTurnId === null && + primaryBranchDiffPreview.error?.includes("configured workspace root") === true && + serverConfig?.cwd !== undefined && + serverConfig.cwd !== activeCwd; + const fallbackBranchDiffPreview = useEnvironmentQuery( + shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig + ? reviewEnvironment.diffPreview({ + environmentId: activeThread.environmentId, + input: { + cwd: serverConfig.cwd, + ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ignoreWhitespace: diffIgnoreWhitespace, + }, + }) + : null, + ); + const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd + ? fallbackBranchDiffPreview + : primaryBranchDiffPreview; + const selectedGitSource = branchDiffPreview.data?.sources.find( + (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + ); + const localBranchRefs = useEnvironmentQuery( + selectedTurnId === null && + selectedGitScope === "branch" && + activeThread && + branchDiffPreview.data?.cwd + ? vcsEnvironment.listRefs({ + environmentId: activeThread.environmentId, + input: { + cwd: branchDiffPreview.data.cwd, + includeMatchingRemoteRefs: true, + refKind: "local", + ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), + limit: 100, + }, + }) + : null, + ); + const remoteBranchRefs = useEnvironmentQuery( + selectedTurnId === null && + selectedGitScope === "branch" && + activeThread && + branchDiffPreview.data?.cwd + ? vcsEnvironment.listRefs({ + environmentId: activeThread.environmentId, + input: { + cwd: branchDiffPreview.data.cwd, + includeMatchingRemoteRefs: true, + refKind: "remote", + ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), + limit: 100, + }, + }) + : null, + ); + const baseRefChoices = buildBaseRefChoices( + localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [], + remoteBranchRefs.data?.refs ?? [], + ); + const matchingBaseRefChoices = filterBaseRefChoices(baseRefChoices, baseRefQuery); + const valueForBaseRefChoice = (choice: (typeof baseRefChoices)[number]) => + selectedBaseRef && selectedBaseRef === choice.remote?.name + ? selectedBaseRef + : (choice.local?.name ?? choice.remote?.name ?? choice.id); + const baseRefItems = [AUTOMATIC_BASE_REF, ...baseRefChoices.map(valueForBaseRefChoice)]; + const filteredBaseRefItems = [ + ...(baseRefQuery.trim().length === 0 ? [AUTOMATIC_BASE_REF] : []), + ...matchingBaseRefChoices.map(valueForBaseRefChoice), + ]; + const gitDiff = selectedGitSource?.diff; + + const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff; + const isSelectedPatchTruncated = !selectedTurn && selectedGitSource?.truncated === true; + const isLoadingSelectedPatch = selectedTurn + ? activeCheckpointDiff.isPending + : branchDiffPreview.isPending; + const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( - () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), - [resolvedTheme, selectedPatch], + () => + getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, { + compactPartialHunkOffsets: selectedTurnId === null, + }), + [resolvedTheme, selectedPatch, selectedTurnId], ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { @@ -303,37 +423,26 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff }), ); }, [renderablePatch]); + const codeViewFiles = useMemo( + () => + renderableFiles.map((fileDiff) => { + const fileKey = buildFileDiffRenderKey(fileDiff); + return { + fileDiff, + filePath: resolveFileDiffPath(fileDiff), + fileKey, + collapsed: collapsedDiffFileKeys.has(fileKey), + }; + }), + [collapsedDiffFileKeys, renderableFiles], + ); useEffect(() => { - if (renderableFiles.length === 0) { - setCollapsedDiffFileKeys((current) => (current.size === 0 ? current : new Set())); - return; - } - - const visibleFileKeys = new Set(renderableFiles.map(buildFileDiffRenderKey)); - setCollapsedDiffFileKeys((current) => { - const next = new Set([...current].filter((fileKey) => visibleFileKeys.has(fileKey))); - return next.size === current.size ? current : next; - }); - }, [renderableFiles]); - - useEffect(() => { - if (diffOpen && !previousDiffOpenRef.current) { - setDiffWordWrap(settings.diffWordWrap); - setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace); - } - previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]); - - useEffect(() => { - if (!selectedFilePath || !patchViewportRef.current) { - return; - } - const target = Array.from( - patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), - ).find((element) => element.dataset.diffFilePath === selectedFilePath); - target?.scrollIntoView({ block: "nearest" }); - }, [selectedFilePath, renderableFiles]); + if (!selectedFilePath) return; + const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath); + if (!file) return; + codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" }); + }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]); const openDiffFile = useCallback( (filePath: string) => { @@ -342,209 +451,226 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff filePath, activeCwd, openInEditor: (targetPath) => { - const api = readLocalApi(); - if (!api) return; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); - }); + void (async () => { + const result = await openInPreferredEditor(targetPath); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to open diff file in editor.", { + operation: "open-diff-file", + ...(routeThreadRef + ? { + environmentId: routeThreadRef.environmentId, + threadId: routeThreadRef.threadId, + } + : {}), + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); + } + })(); }, }); }, - [activeCwd, routeThreadRef], + [activeCwd, openInPreferredEditor, routeThreadRef], + ); + const toggleDiffFileCollapsed = useCallback( + (fileKey: string) => { + setCollapsedDiffFiles((current) => { + const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + if (next.has(fileKey)) { + next.delete(fileKey); + } else { + next.add(fileKey); + } + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + }, + [collapseScopeKey], ); - const toggleDiffFileCollapsed = useCallback((fileKey: string) => { - setCollapsedDiffFileKeys((current) => { - const next = new Set(current); - if (next.has(fileKey)) { - next.delete(fileKey); - } else { - next.add(fileKey); - } - return next; - }); - }, []); const selectTurn = (turnId: TurnId) => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); }; - const selectWholeConversation = () => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); + const selectGitScope = (scope: "branch" | "unstaged") => { + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope); + }; + const selectBranchBaseRef = (baseRef: string | null) => { + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; - const updateTurnStripScrollState = useCallback(() => { - const element = turnStripRef.current; - if (!element) { - setCanScrollTurnStripLeft(false); - setCanScrollTurnStripRight(false); - return; - } - - const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); - setCanScrollTurnStripLeft(element.scrollLeft > 4); - setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); - }, []); - const scrollTurnStripBy = useCallback((offset: number) => { - const element = turnStripRef.current; - if (!element) return; - element.scrollBy({ left: offset, behavior: "smooth" }); - }, []); - const onTurnStripWheel = useCallback((event: ReactWheelEvent) => { - const element = turnStripRef.current; - if (!element) return; - if (element.scrollWidth <= element.clientWidth + 1) return; - if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; - - event.preventDefault(); - element.scrollBy({ left: event.deltaY, behavior: "auto" }); - }, []); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - const onScroll = () => updateTurnStripScrollState(); - - element.addEventListener("scroll", onScroll, { passive: true }); - - const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); - resizeObserver.observe(element); - - return () => { - window.cancelAnimationFrame(frameId); - element.removeEventListener("scroll", onScroll); - resizeObserver.disconnect(); - }; - }, [updateTurnStripScrollState]); - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); - selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); const headerRow = ( <> -
- - -
- - {orderedTurnDiffSummaries.map((summary) => ( - - selectTurn(summary.turnId)} - data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} - /> - } + Latest turn + {selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId && ( + + )} + + + Turn + + {orderedTurnDiffSummaries.map((summary) => { + const turnCount = + summary.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[summary.turnId] ?? + "?"; + return ( + selectTurn(summary.turnId)} + > + Turn {turnCount} + + {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + + {summary.turnId === selectedTurn?.turnId && } + + ); + })} + + + + + {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( +
+ {selectedGitSource.headRef ?? "HEAD"} + + { + if (!open) setBaseRefQuery(""); + }} + onValueChange={(value) => { + if (!value) return; + selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value); + }} + > + + {selectedGitSource.baseRef} + + + -
-
- - Turn{" "} - {summary.checkpointTurnCount ?? - inferredCheckpointTurnCountByTurnId[summary.turnId] ?? - "?"} - - - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} - +
+
+
- - {summary.turnId} - - ))} -
+
+
+ No matching refs. + + + Automatic + + {baseRefChoices.map((choice) => { + const item = valueForBaseRefChoice(choice); + const hasBoth = choice.local !== null && choice.remote !== null; + const useRemote = choice.remote?.name === item; + return ( + +
+ {choice.label} + {hasBoth ? ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + { + const nextRef = checked + ? choice.remote?.name + : choice.local?.name; + if (nextRef) selectBranchBaseRef(nextRef); + }} + /> +
+ ) : choice.remote ? ( + + + ) : null} +
+
+ ); + })} +
+ + +
+ )}
{ - setDiffWordWrap(Boolean(pressed)); + setWordWrap(Boolean(pressed)); }} /> } @@ -585,7 +709,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff - {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} @@ -624,24 +748,35 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
Turn diffs are unavailable because this project is not a git repository.
- ) : orderedTurnDiffSummaries.length === 0 ? ( + ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? (
No completed turns yet.
) : ( <> -
- {checkpointDiffError && !renderablePatch && ( +
+ {isSelectedPatchTruncated && ( +

+ This diff was truncated because it exceeded the preview limit. The changes shown are + incomplete. +

+ )} + {selectedPatchError && !renderablePatch && (
-

{checkpointDiffError}

+

{selectedPatchError}

)} {!renderablePatch ? ( - isLoadingCheckpointDiff ? ( - + isLoadingSelectedPatch ? ( + ) : (

@@ -652,94 +787,79 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff

) ) : renderablePatch.kind === "files" ? ( - { + const composedPath = event.nativeEvent.composedPath?.() ?? []; + const title = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-title"), + ); + const filePath = title?.textContent?.trim(); + if (filePath) openDiffFile(filePath); }} > - {renderableFiles.map((fileDiff) => { - const filePath = resolveFileDiffPath(fileDiff); - const fileKey = buildFileDiffRenderKey(fileDiff); - const themedFileKey = `${fileKey}:${resolvedTheme}`; - const collapsed = collapsedDiffFileKeys.has(fileKey); - return ( -
{ - const nativeEvent = event.nativeEvent as MouseEvent; - const composedPath = nativeEvent.composedPath?.() ?? []; - const clickedHeader = composedPath.some((node) => { - if (!(node instanceof Element)) return false; - return node.hasAttribute("data-title"); - }); - if (!clickedHeader) return; - openDiffFile(filePath); - }} - > - ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - {collapsed ? ( - - ) : ( - + { + const filePath = resolveFileDiffPath(fileDiff); + return ( + + - - {collapsed ? "Expand diff" : "Collapse diff"} - - - )} - options={{ - collapsed, - diffStyle: diffRenderMode === "split" ? "split" : "unified", - lineDiffType: "none", - overflow: diffWordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), - themeType: resolvedTheme as DiffThemeType, - unsafeCSS: DIFF_PANEL_UNSAFE_CSS, - }} - /> -
- ); - })} -
+ aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`} + aria-expanded={!collapsed} + onClick={(event) => { + event.stopPropagation(); + toggleDiffFileCollapsed(fileKey); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + + {collapsed ? "Expand diff" : "Collapse diff"} + + + ); + }} + options={{ + diffStyle: diffRenderMode === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: wordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme as DiffThemeType, + unsafeCSS: DIFF_PANEL_UNSAFE_CSS, + stickyHeaders: true, + layout: { paddingTop: 8, paddingBottom: 8, gap: 8 }, + }} + /> +
) : ( -
+

{renderablePatch.reason}

 {
-  it("uses the shared compact surface subheader in embedded mode", async () => {
-    const screen = await render(
-      Diff controls}>
-        
Diff content
-
, - ); - const subheader = screen.container.querySelector("[data-surface-subheader]"); - - expect(subheader).not.toBeNull(); - expect(subheader?.getBoundingClientRect().height).toBe(40); - expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); - expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); - }); -}); diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 4dd569d2823..e727a80055d 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -48,14 +48,8 @@ export function DiffPanelShell(props: { export function DiffPanelHeaderSkeleton() { return ( <> -
- - -
- - - -
+
+
diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 8f7addc5bc7..3ec748c6bcb 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -1,9 +1,20 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; +import * as Schema from "effect/Schema"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +export class DiffWorkerError extends Schema.TaggedErrorClass()("DiffWorkerError", { + operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), + themeName: Schema.Literals(["pierre-light", "pierre-dark"]), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Diff worker operation ${this.operation} failed for theme ${this.themeName}.`; + } +} + function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); @@ -12,17 +23,23 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { return; } - const current = workerPool.getDiffRenderOptions(); - if (current.theme === themeName) { - return; - } + let operation: DiffWorkerError["operation"] = "get-render-options"; + void (async () => { + try { + const current = workerPool.getDiffRenderOptions(); + if (current.theme === themeName) { + return; + } - void workerPool - .setRenderOptions({ - ...current, - theme: themeName, - }) - .catch(() => undefined); + operation = "set-render-options"; + await workerPool.setRenderOptions({ + ...current, + theme: themeName, + }); + } catch (cause) { + console.error(new DiffWorkerError({ operation, themeName, cause })); + } + })(); }, [themeName, workerPool]); return null; @@ -40,7 +57,17 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { return ( new DiffsWorker(), + workerFactory: () => { + try { + return new DiffsWorker(); + } catch (cause) { + throw new DiffWorkerError({ + operation: "create-worker", + themeName: diffThemeName, + cause, + }); + } + }, poolSize: workerPoolSize, totalASTLRUCacheSize: 240, }} diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx deleted file mode 100644 index 996bf5ff8fc..00000000000 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ /dev/null @@ -1,457 +0,0 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { ThreadId } from "@t3tools/contracts"; -import { useState } from "react"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -const SHARED_THREAD_ID = ThreadId.make("thread-shared"); -const ENVIRONMENT_A = "environment-local" as never; -const ENVIRONMENT_B = "environment-remote" as never; -const GIT_CWD = "/repo/project"; -const BRANCH_NAME = "feature/toast-scope"; - -function createDeferredPromise() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - - const promise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve; - reject = nextReject; - }); - - return { promise, resolve, reject }; -} - -const { - activeRunStackedActionDeferredRef, - activeDraftThreadRef, - hasServerThreadRef, - invalidateSourceControlStateSpy, - refreshVcsStatusSpy, - runStackedActionSpy, - setDraftThreadContextSpy, - setThreadBranchSpy, - toastAddSpy, - toastCloseSpy, - toastPromiseSpy, - toastUpdateSpy, -} = vi.hoisted(() => ({ - activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, - activeDraftThreadRef: { current: null as unknown }, - hasServerThreadRef: { current: true }, - invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()), - refreshVcsStatusSpy: vi.fn(() => Promise.resolve(null)), - runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), - setDraftThreadContextSpy: vi.fn(), - setThreadBranchSpy: vi.fn(), - toastAddSpy: vi.fn(() => "toast-1"), - toastCloseSpy: vi.fn(), - toastPromiseSpy: vi.fn(), - toastUpdateSpy: vi.fn(), -})); - -vi.mock("~/components/ui/toast", () => ({ - toastManager: { - add: toastAddSpy, - close: toastCloseSpy, - promise: toastPromiseSpy, - update: toastUpdateSpy, - }, - stackedThreadToast: vi.fn((options: unknown) => options), -})); - -vi.mock("~/editorPreferences", () => ({ - openInPreferredEditor: vi.fn(), -})); - -vi.mock("~/lib/sourceControlActions", () => ({ - invalidateSourceControlState: invalidateSourceControlStateSpy, - useGitStackedAction: vi.fn(() => ({ - error: null, - isPending: false, - resetError: vi.fn(), - run: runStackedActionSpy, - })), - useSourceControlActionRunning: vi.fn(() => false), - useSourceControlPublishRepositoryAction: vi.fn(() => ({ - error: null, - isPending: false, - resetError: vi.fn(), - run: vi.fn(), - })), - useVcsInitAction: vi.fn(() => ({ - error: null, - isPending: false, - resetError: vi.fn(), - run: vi.fn(), - })), - useVcsPullAction: vi.fn(() => ({ - error: null, - isPending: false, - resetError: vi.fn(), - run: vi.fn(), - })), -})); - -vi.mock("~/lib/vcsStatusState", () => ({ - getVcsStatusDataForTarget: (state: { data: unknown }) => state.data, - refreshVcsStatus: refreshVcsStatusSpy, - resetVcsStatusStateForTests: () => undefined, - useVcsStatus: vi.fn(() => ({ - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: BRANCH_NAME, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 1, - behindCount: 0, - pr: null, - }, - error: null, - isPending: false, - })), -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(() => { - throw new Error("ensureLocalApi not implemented in browser test"); - }), - readLocalApi: vi.fn(() => null), -})); - -vi.mock("~/composerDraftStore", async () => { - const draftStoreState = { - getDraftThreadByRef: () => activeDraftThreadRef.current, - getDraftSession: () => activeDraftThreadRef.current, - getDraftThread: () => activeDraftThreadRef.current, - getDraftSessionByLogicalProjectKey: () => null, - setDraftThreadContext: setDraftThreadContextSpy, - setLogicalProjectDraftThreadId: vi.fn(), - setProjectDraftThreadId: vi.fn(), - hasDraftThreadsInEnvironment: () => false, - clearDraftThread: vi.fn(), - }; - - return { - DraftId: { - makeUnsafe: (value: string) => value, - }, - useComposerDraftStore: Object.assign( - (selector: (state: unknown) => unknown) => selector(draftStoreState), - { getState: () => draftStoreState }, - ), - markPromotedDraftThread: vi.fn(), - markPromotedDraftThreadByRef: vi.fn(), - markPromotedDraftThreads: vi.fn(), - markPromotedDraftThreadsByRef: vi.fn(), - finalizePromotedDraftThreadByRef: vi.fn(), - finalizePromotedDraftThreadsByRef: vi.fn(), - }; -}); - -vi.mock("~/store", () => ({ - selectEnvironmentState: ( - state: { environmentStateById: Record }, - environmentId: string | null, - ) => { - if (!environmentId) { - throw new Error("Missing environment id"); - } - const environmentState = state.environmentStateById[environmentId]; - if (!environmentState) { - throw new Error(`Unknown environment: ${environmentId}`); - } - return environmentState; - }, - selectProjectsForEnvironment: () => [], - selectProjectsAcrossEnvironments: () => [], - selectThreadsForEnvironment: () => [], - selectThreadsAcrossEnvironments: () => [], - selectThreadShellsAcrossEnvironments: () => [], - selectSidebarThreadsAcrossEnvironments: () => [], - selectSidebarThreadsForProjectRef: () => [], - selectSidebarThreadsForProjectRefs: () => [], - selectBootstrapCompleteForActiveEnvironment: () => true, - selectProjectByRef: () => null, - selectThreadByRef: () => null, - selectSidebarThreadSummaryByRef: () => null, - selectThreadIdsByProjectRef: () => [], - useStore: (selector: (state: unknown) => unknown) => - selector({ - setThreadBranch: setThreadBranchSpy, - environmentStateById: { - [ENVIRONMENT_A]: { - threadShellById: hasServerThreadRef.current - ? { - [SHARED_THREAD_ID]: { - id: SHARED_THREAD_ID, - branch: BRANCH_NAME, - worktreePath: null, - }, - } - : {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - }, - [ENVIRONMENT_B]: { - threadShellById: hasServerThreadRef.current - ? { - [SHARED_THREAD_ID]: { - id: SHARED_THREAD_ID, - branch: BRANCH_NAME, - worktreePath: null, - }, - } - : {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - }, - }, - }), -})); - -vi.mock("~/terminal-links", () => ({ - resolvePathLinkTarget: vi.fn(), -})); - -import GitActionsControl from "./GitActionsControl"; - -function findButtonByText(text: string): HTMLButtonElement | null { - return (Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.includes(text), - ) ?? null) as HTMLButtonElement | null; -} - -function Harness() { - const [activeThreadRef, setActiveThreadRef] = useState( - scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), - ); - - return ( - <> - - - - ); -} - -describe("GitActionsControl thread-scoped progress toast", () => { - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - activeRunStackedActionDeferredRef.current = createDeferredPromise(); - activeDraftThreadRef.current = null; - hasServerThreadRef.current = true; - document.body.innerHTML = ""; - }); - - it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => { - vi.useFakeTimers(); - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render(, { container: host }); - - try { - const quickActionButton = findButtonByText("Push & create PR"); - expect(quickActionButton, 'Unable to find button containing "Push & create PR"').toBeTruthy(); - if (!(quickActionButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Push & create PR"'); - } - quickActionButton.click(); - - expect(toastAddSpy).toHaveBeenCalledWith( - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - - await vi.advanceTimersByTimeAsync(1_000); - - expect(toastUpdateSpy).toHaveBeenLastCalledWith( - "toast-1", - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - - const switchEnvironmentButton = findButtonByText("Switch environment"); - expect( - switchEnvironmentButton, - 'Unable to find button containing "Switch environment"', - ).toBeTruthy(); - if (!(switchEnvironmentButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Switch environment"'); - } - switchEnvironmentButton.click(); - await vi.advanceTimersByTimeAsync(1_000); - - expect(toastUpdateSpy).toHaveBeenLastCalledWith( - "toast-1", - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - } finally { - activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); - await Promise.resolve(); - vi.useRealTimers(); - await screen.unmount(); - host.remove(); - } - }); - - it("debounces focus-driven git status refreshes", async () => { - vi.useFakeTimers(); - - const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); - let visibilityState: DocumentVisibilityState = "hidden"; - Object.defineProperty(document, "visibilityState", { - configurable: true, - get: () => visibilityState, - }); - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - window.dispatchEvent(new Event("focus")); - visibilityState = "visible"; - document.dispatchEvent(new Event("visibilitychange")); - - expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(249); - expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(refreshVcsStatusSpy).toHaveBeenCalledTimes(1); - expect(refreshVcsStatusSpy).toHaveBeenCalledWith({ - environmentId: ENVIRONMENT_A, - cwd: GIT_CWD, - }); - } finally { - if (originalVisibilityState) { - Object.defineProperty(document, "visibilityState", originalVisibilityState); - } - vi.useRealTimers(); - await screen.unmount(); - host.remove(); - } - }); - - it("syncs the live branch into the active draft thread when no server thread exists", async () => { - hasServerThreadRef.current = false; - activeDraftThreadRef.current = { - threadId: SHARED_THREAD_ID, - environmentId: ENVIRONMENT_A, - branch: null, - worktreePath: null, - }; - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - await Promise.resolve(); - - expect(setDraftThreadContextSpy).toHaveBeenCalledWith( - scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), - { - branch: BRANCH_NAME, - worktreePath: null, - }, - ); - expect(setThreadBranchSpy).not.toHaveBeenCalled(); - } finally { - await screen.unmount(); - host.remove(); - } - }); - - it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { - hasServerThreadRef.current = false; - activeDraftThreadRef.current = { - threadId: SHARED_THREAD_ID, - environmentId: ENVIRONMENT_A, - branch: "feature/base-branch", - worktreePath: null, - envMode: "worktree", - }; - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - await Promise.resolve(); - - expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); - expect(setThreadBranchSpy).not.toHaveBeenCalled(); - } finally { - await screen.unmount(); - host.remove(); - } - }); -}); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 8c7356e2829..c9816719452 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,4 +1,9 @@ +import { useAtomValue } from "@effect/atom-react"; import { type ScopedThreadRef } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { GitActionProgressEvent, GitRunStackedActionResult, @@ -63,7 +68,7 @@ import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { openInPreferredEditor } from "~/editorPreferences"; +import { useOpenInPreferredEditor } from "~/editorPreferences"; import { useGitStackedAction, useSourceControlActionRunning, @@ -71,16 +76,19 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { getVcsStatusDataForTarget, refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; -import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; -import { newCommandId, randomUUID } from "~/lib/utils"; +import { useThread } from "~/state/entities"; +import { useEnvironmentQuery } from "~/state/query"; +import { serverEnvironment } from "~/state/server"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { vcsEnvironment } from "~/state/vcs"; +import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; -import { useStore } from "~/store"; -import { createThreadSelectorByRef } from "~/storeSelectors"; +import { openPullRequestLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { gitCwd: string | null; @@ -128,6 +136,22 @@ interface RunGitActionWithToastInput { } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; + +type RefreshVcsStatus = (target: { + readonly environmentId: ScopedThreadRef["environmentId"]; + readonly input: { readonly cwd: string }; +}) => Promise; + +function requestVcsStatusRefresh( + refresh: RefreshVcsStatus, + environmentId: ScopedThreadRef["environmentId"] | null, + cwd: string | null, +): void { + if (environmentId === null || cwd === null) { + return; + } + void refresh({ environmentId, input: { cwd } }); +} const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; const PUBLISH_PROVIDER_OPTIONS = [ @@ -348,9 +372,17 @@ interface PublishRepositoryDialogProps { function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const navigate = useNavigate(); - const sourceControlDiscovery = useSourceControlDiscovery(); - const [publishProvider, setPublishProvider] = useState("github"); - const [publishRepository, setPublishRepository] = useState(""); + const sourceControlDiscovery = useEnvironmentQuery( + props.environmentId === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: props.environmentId, + input: {}, + }), + ); + const [selectedPublishProvider, setSelectedPublishProvider] = + useState(null); + const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null); const [publishVisibility, setPublishVisibility] = useState("private"); const [publishRemoteName, setPublishRemoteName] = useState("origin"); @@ -361,7 +393,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const [publishResult, setPublishResult] = useState( null, ); - const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); const sourceControlScope = useMemo( () => ({ environmentId: props.environmentId, @@ -412,10 +443,18 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { }), [publishProviderReadiness], ); + const firstReadyPublishProvider = sortedPublishProviderOptions.find( + (option) => publishProviderReadiness[option.value].ready, + )?.value; + const publishProvider = + selectedPublishProvider !== null && publishProviderReadiness[selectedPublishProvider].ready + ? selectedPublishProvider + : (firstReadyPublishProvider ?? selectedPublishProvider ?? "github"); const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; const publishRepositoryPrefill = publishAccountByProvider[publishProvider] ? `${publishAccountByProvider[publishProvider]}/` : ""; + const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; const currentPublishProvider = publishProviderOption(publishProvider); const publishHost = currentPublishProvider.host; const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; @@ -427,13 +466,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { null, ] as const; - useEffect(() => { - if (!props.open || hasUserEditedPublishRepository) { - return; - } - setPublishRepository(publishRepositoryPrefill); - }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); - const canSubmitPublishRepository = useMemo(() => { if (!selectedPublishProviderReadiness.ready) return false; if (publishRepositoryAction.isPending) return false; @@ -444,21 +476,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { return owner.length > 0 && name.length > 0; }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]); - useEffect(() => { - if (!props.open) { - return; - } - if (publishProviderReadiness[publishProvider].ready) { - return; - } - const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( - (option) => publishProviderReadiness[option.value].ready, - ); - if (firstReadyProvider) { - setPublishProvider(firstReadyProvider.value); - } - }, [props.open, publishProvider, publishProviderReadiness]); - const submitPublishRepository = useCallback(() => { if (!canSubmitPublishRepository) { return; @@ -466,26 +483,28 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishError(null); - void publishRepositoryAction - .run({ + void (async () => { + const result = await publishRepositoryAction.run({ provider: publishProvider, repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", protocol: publishProtocol, - }) - .then((result) => { - flushSync(() => { - setPublishResult(result); - setPublishWizardStep(2); - }); - void refreshVcsStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( - () => undefined, - ); - }) - .catch((err: unknown) => { - setPublishError(err instanceof Error ? err.message : "An error occurred."); }); + + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setPublishError(error instanceof Error ? error.message : "An error occurred."); + } + return; + } + + flushSync(() => { + setPublishResult(result.value); + setPublishWizardStep(2); + }); + })(); }, [ canSubmitPublishRepository, props.environmentId, @@ -500,8 +519,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const resetState = useCallback(() => { setPublishRemoteName("origin"); - setPublishRepository(""); - setHasUserEditedPublishRepository(false); + setPublishRepositoryOverride(null); setPublishWizardStep(0); setPublishAdvancedOpen(false); setPublishError(null); @@ -594,7 +612,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishProvider(value as PublishProviderKind)} + onValueChange={(value) => { + setSelectedPublishProvider(value as PublishProviderKind); + setPublishRepositoryOverride(null); + }} aria-labelledby="publish-provider-cards-label" className="grid grid-cols-2 gap-2.5" > @@ -680,8 +701,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { name="publish-repository-path" value={publishRepository} onChange={(event) => { - setPublishRepository(event.target.value); - setHasUserEditedPublishRepository(true); + setPublishRepositoryOverride(event.target.value); }} onKeyDown={(event) => { if (event.key === "Enter") { @@ -951,16 +971,21 @@ export default function GitActionsControl({ activeThreadRef, draftId, }: GitActionsControlProps) { + const updateThreadMetadata = useAtomCommand( + threadEnvironment.updateMetadata, + "thread branch metadata update", + ); const activeEnvironmentId = activeThreadRef?.environmentId ?? null; + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); + const openInPreferredEditor = useOpenInPreferredEditor( + activeEnvironmentId, + serverConfig?.availableEditors ?? [], + ); const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); - const activeServerThreadSelector = useMemo( - () => createThreadSelectorByRef(activeThreadRef), - [activeThreadRef], - ); - const activeServerThread = useStore(activeServerThreadSelector); + const activeServerThread = useThread(activeThreadRef); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -969,7 +994,6 @@ export default function GitActionsControl({ : null, ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const setThreadBranch = useStore((store) => store.setThreadBranch); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); @@ -1010,20 +1034,15 @@ export default function GitActionsControl({ } const worktreePath = activeServerThread.worktreePath; - const api = readEnvironmentApi(activeThreadRef.environmentId); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadRef.threadId, - branch, - worktreePath, - }) - .catch(() => undefined); - } + void updateThreadMetadata({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + branch, + worktreePath, + }, + }); - setThreadBranch(activeThreadRef, branch, worktreePath); return; } @@ -1042,7 +1061,7 @@ export default function GitActionsControl({ activeThreadRef, draftId, setDraftThreadContext, - setThreadBranch, + updateThreadMetadata, ], ); @@ -1058,13 +1077,18 @@ export default function GitActionsControl({ [persistThreadBranchSync], ); - const vcsStatusTarget = useMemo( - () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), - [activeEnvironmentId, gitCwd], + const gitStatusQuery = useEnvironmentQuery( + activeEnvironmentId !== null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: activeEnvironmentId, + input: { cwd: gitCwd }, + }) + : null, ); - const gitStatusQuery = useVcsStatus(vcsStatusTarget); - const { error: gitStatusError } = gitStatusQuery; - const gitStatus = getVcsStatusDataForTarget(gitStatusQuery, vcsStatusTarget); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { + reportFailure: false, + }); + const { data: gitStatus, error: gitStatusError } = gitStatusQuery; const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], @@ -1166,9 +1190,7 @@ export default function GitActionsControl({ } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; - void refreshVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( - () => undefined, - ); + requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { @@ -1187,7 +1209,7 @@ export default function GitActionsControl({ window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [activeEnvironmentId, gitCwd]); + }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); const openExistingPr = useCallback(async () => { const api = readLocalApi(); @@ -1208,7 +1230,8 @@ export default function GitActionsControl({ }); return; } - void api.shell.openExternal(prUrl).catch((err: unknown) => { + void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => { + console.error(err); toastManager.add( stackedThreadToast({ type: "error", @@ -1356,7 +1379,7 @@ export default function GitActionsControl({ // elapsed description visible until the final success state renders. return; case "action_failed": - // Let the rejected mutation publish the error toast to avoid a + // Let the settled mutation publish the error toast to avoid a // transient intermediate state before the final failure message. return; } @@ -1364,7 +1387,7 @@ export default function GitActionsControl({ updateActiveProgressToast(); }; - const promise = runImmediateGitAction.run({ + const result = await runImmediateGitAction.run({ actionId, action, ...(commitMessage ? { commitMessage } : {}), @@ -1373,78 +1396,84 @@ export default function GitActionsControl({ onProgress: applyProgressEvent, }); - try { - const result = await promise; - activeGitActionProgressRef.current = null; - syncThreadBranchAfterGitAction(result); - const closeResultToast = () => { + activeGitActionProgressRef.current = null; + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { toastManager.close(resolvedProgressToastId); - }; - - const toastCta = result.toast.cta; - let toastActionProps: { - children: string; - onClick: () => void; - } | null = null; - if (toastCta.kind === "run_action") { - toastActionProps = { - children: toastCta.label, - onClick: () => { - closeResultToast(); - void runGitActionWithToast({ - action: toastCta.action.kind, - }); - }, - }; - } else if (toastCta.kind === "open_pr") { - toastActionProps = { - children: toastCta.label, - onClick: () => { - const api = readLocalApi(); - if (!api) return; - closeResultToast(); - void api.shell.openExternal(toastCta.url); - }, - }; + return; } - const successToastData = { - ...scopedToastData, - dismissAfterVisibleMs: 10_000, - }; - - if (toastActionProps) { - toastManager.update( - resolvedProgressToastId, - stackedThreadToast({ - type: "success", - title: result.toast.title, - description: result.toast.description, - timeout: 0, - actionProps: toastActionProps, - data: successToastData, - }), - ); - } else { - toastManager.update(resolvedProgressToastId, { - type: "success", - title: result.toast.title, - description: result.toast.description, - timeout: 0, - data: successToastData, - }); - } - } catch (err) { - activeGitActionProgressRef.current = null; + const error = squashAtomCommandFailure(result); toastManager.update( resolvedProgressToastId, stackedThreadToast({ type: "error", title: "Action failed", - description: err instanceof Error ? err.message : "An error occurred.", + description: error instanceof Error ? error.message : "An error occurred.", ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), }), ); + return; + } + + const actionResult = result.value; + syncThreadBranchAfterGitAction(actionResult); + const closeResultToast = () => { + toastManager.close(resolvedProgressToastId); + }; + + const toastCta = actionResult.toast.cta; + let toastActionProps: { + children: string; + onClick: () => void; + } | null = null; + if (toastCta.kind === "run_action") { + toastActionProps = { + children: toastCta.label, + onClick: () => { + closeResultToast(); + void runGitActionWithToast({ + action: toastCta.action.kind, + }); + }, + }; + } else if (toastCta.kind === "open_pr") { + toastActionProps = { + children: toastCta.label, + onClick: () => { + const api = readLocalApi(); + if (!api) return; + closeResultToast(); + void api.shell.openExternal(toastCta.url); + }, + }; + } + + const successToastData = { + ...scopedToastData, + dismissAfterVisibleMs: 10_000, + }; + + if (toastActionProps) { + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "success", + title: actionResult.toast.title, + description: actionResult.toast.description, + timeout: 0, + actionProps: toastActionProps, + data: successToastData, + }), + ); + } else { + toastManager.update(resolvedProgressToastId, { + type: "success", + title: actionResult.toast.title, + description: actionResult.toast.description, + timeout: 0, + data: successToastData, + }); } }, ); @@ -1504,27 +1533,43 @@ export default function GitActionsControl({ return; } if (quickAction.kind === "run_pull") { - const promise = pullAction.run(); - void toastManager.promise>, ThreadToastData>( - promise, - { - loading: { title: "Pulling...", data: threadToastData }, - success: (result) => ({ - title: result.status === "pulled" ? "Pulled" : "Already up to date", - description: - result.status === "pulled" - ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` - : `${result.refName} is already synchronized.`, - data: threadToastData, - }), - error: (err) => ({ - title: "Pull failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }), - }, - ); - void promise.catch(() => undefined); + const toastId = toastManager.add({ + type: "loading", + title: "Pulling...", + timeout: 0, + data: threadToastData, + }); + void (async () => { + const result = await pullAction.run(); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + return; + } + const error = squashAtomCommandFailure(result); + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Pull failed", + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + return; + } + + const pullResult = result.value; + toastManager.update(toastId, { + type: "success", + title: pullResult.status === "pulled" ? "Pulled" : "Already up to date", + description: + pullResult.status === "pulled" + ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` + : `${pullResult.refName} is already synchronized.`, + data: threadToastData, + }); + })(); return; } if (quickAction.kind === "show_hint") { @@ -1576,8 +1621,7 @@ export default function GitActionsControl({ const openChangedFileInEditor = useCallback( (filePath: string) => { - const api = readLocalApi(); - if (!api || !gitCwd) { + if (!gitCwd) { toastManager.add({ type: "error", title: "Editor opening is unavailable.", @@ -1586,7 +1630,12 @@ export default function GitActionsControl({ return; } const target = resolvePathLinkTarget(filePath, gitCwd); - void openInPreferredEditor(api, target).catch((error) => { + void (async () => { + const result = await openInPreferredEditor(target); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1595,9 +1644,9 @@ export default function GitActionsControl({ ...(threadToastData !== undefined ? { data: threadToastData } : {}), }), ); - }); + })(); }, - [gitCwd, threadToastData], + [gitCwd, openInPreferredEditor, threadToastData], ); const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; @@ -1612,7 +1661,21 @@ export default function GitActionsControl({ size="xs" disabled={initAction.isPending} onClick={() => { - void initAction.run(); + void (async () => { + const result = await initAction.run(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Git initialization failed", + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + })(); }} > @@ -1664,10 +1727,7 @@ export default function GitActionsControl({ { if (open) { - void refreshVcsStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - }).catch(() => undefined); + requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); } }} > @@ -1748,7 +1808,7 @@ export default function GitActionsControl({

)} {gitStatusError && ( -

{gitStatusError.message}

+

{gitStatusError}

)}
diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx deleted file mode 100644 index e9006f5188f..00000000000 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ /dev/null @@ -1,640 +0,0 @@ -import "../index.css"; - -import { - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - ORCHESTRATION_WS_METHODS, - type MessageId, - type OrchestrationReadModel, - type ProjectId, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type ServerLifecycleWelcomePayload, - ServerConfig as ServerConfigSchema, - ServerSettings, - type ThreadId, - WS_METHODS, -} from "@t3tools/contracts"; -import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; -import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; -import { ws, http, HttpResponse } from "msw"; -import { setupWorker } from "msw/browser"; -import * as Schema from "effect/Schema"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { useComposerDraftStore } from "../composerDraftStore"; -import { __resetLocalApiForTests } from "../localApi"; -import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; -import { getServerConfig, getServerConfigUpdatedNotification } from "../rpc/serverState"; -import { getWsConnectionStatus } from "../rpc/wsConnectionState"; -import { getRouter } from "../router"; -import { useStore } from "../store"; -import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; -import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; - -vi.mock("../lib/vcsStatusState", () => { - const status = { - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: true, - refName: "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }, - error: null, - cause: null, - isPending: false, - }; - - return { - getVcsStatusDataForTarget: (state: typeof status) => state.data, - getVcsStatusSnapshot: () => status, - useVcsStatus: () => status, - useVcsStatuses: () => new Map(), - refreshVcsStatus: () => Promise.resolve(null), - resetVcsStatusStateForTests: () => undefined, - }; -}); - -const THREAD_ID = "thread-kb-toast-test" as ThreadId; -const PROJECT_ID = "project-1" as ProjectId; -const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const NOW_ISO = "2026-03-04T12:00:00.000Z"; - -interface TestFixture { - snapshot: OrchestrationReadModel; - serverConfig: ServerConfig; - welcome: ServerLifecycleWelcomePayload; -} - -let fixture: TestFixture; -const rpcHarness = new BrowserWsRpcHarness(); -const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); -const encodeServerSettings = Schema.encodeSync(ServerSettings); - -const wsLink = ws.link(/ws(s)?:\/\/.*/); - -function createBaseServerConfig(): ServerConfig { - return { - environment: { - environmentId: LOCAL_ENVIRONMENT_ID, - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/repo/project", - keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", - keybindings: [], - issues: [], - providers: [ - { - driver: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [], - slashCommands: [], - skills: [], - }, - ], - availableEditors: [], - observability: { - logsDirectoryPath: "/repo/project/.t3/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: false, - defaultThreadEnvMode: "local" as const, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4-mini", - }, - providers: { - codex: { - enabled: true, - binaryPath: "", - homePath: "", - shadowHomePath: "", - customModels: [], - }, - claudeAgent: { - enabled: true, - binaryPath: "", - homePath: "", - customModels: [], - launchArgs: "", - }, - cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, - grok: { enabled: true, binaryPath: "", customModels: [] }, - opencode: { - enabled: true, - binaryPath: "", - serverUrl: "", - serverPassword: "", - customModels: [], - }, - }, - }, - }; -} - -function createMinimalSnapshot(): OrchestrationReadModel { - return { - snapshotSequence: 1, - projects: [ - { - id: PROJECT_ID, - title: "Project", - workspaceRoot: "/repo/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - scripts: [], - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - }, - ], - threads: [ - { - id: THREAD_ID, - projectId: PROJECT_ID, - title: "Test thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "main", - worktreePath: null, - latestTurn: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - archivedAt: null, - deletedAt: null, - messages: [ - { - id: "msg-1" as MessageId, - role: "user", - text: "hello", - turnId: null, - streaming: false, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - }, - ], - activities: [], - proposedPlans: [], - checkpoints: [], - goal: null, - session: { - threadId: THREAD_ID, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW_ISO, - }, - }, - ], - updatedAt: NOW_ISO, - }; -} - -function toShellSnapshot(snapshot: OrchestrationReadModel) { - return { - snapshotSequence: snapshot.snapshotSequence, - projects: snapshot.projects.map((project) => ({ - id: project.id, - title: project.title, - workspaceRoot: project.workspaceRoot, - repositoryIdentity: project.repositoryIdentity ?? null, - defaultModelSelection: project.defaultModelSelection, - scripts: project.scripts, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - })), - threads: snapshot.threads.map((thread) => ({ - id: thread.id, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestTurn: thread.latestTurn, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - archivedAt: thread.archivedAt, - session: thread.session, - latestUserMessageAt: - thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - goal: thread.goal, - })), - updatedAt: snapshot.updatedAt, - }; -} - -function buildFixture(): TestFixture { - return { - snapshot: createMinimalSnapshot(), - serverConfig: createBaseServerConfig(), - welcome: { - environment: { - environmentId: LOCAL_ENVIRONMENT_ID, - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/repo/project", - projectName: "Project", - bootstrapProjectId: PROJECT_ID, - bootstrapThreadId: THREAD_ID, - }, - }; -} - -function resolveWsRpc(tag: string): unknown { - if (tag === WS_METHODS.serverGetConfig) { - return encodeServerConfig(fixture.serverConfig); - } - if (tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, - refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], - }; - } - if (tag === WS_METHODS.projectsSearchEntries) { - return { entries: [], truncated: false }; - } - return {}; -} - -const worker = setupWorker( - wsLink.addEventListener("connection", ({ client }) => { - void rpcHarness.connect(client); - client.addEventListener("message", (event) => { - const rawData = event.data; - if (typeof rawData !== "string") return; - void rpcHarness.onMessage(rawData); - }); - }), - ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })), -); - -function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { - rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { - version: 1, - type: "keybindingsUpdated", - payload: { keybindings: fixture.serverConfig.keybindings, issues }, - }); -} - -function queryToastTitles(): string[] { - return Array.from(document.querySelectorAll('[data-slot="toast-title"]')).map( - (el) => el.textContent ?? "", - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - return element!; -} - -async function waitForComposerEditor(): Promise { - return waitForElement( - () => document.querySelector('[data-testid="composer-editor"]'), - "App should render composer editor", - ); -} - -async function waitForToastViewport(): Promise { - return waitForElement( - () => document.querySelector('[data-slot="toast-viewport"]'), - "App should render the toast viewport before server config updates are pushed", - ); -} - -async function waitForWsConnection(): Promise { - await vi.waitFor( - () => { - expect(getWsConnectionStatus().phase).toBe("connected"); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForToast(title: string, count = 1): Promise { - await vi.waitFor( - () => { - const matches = queryToastTitles().filter((t) => t === title); - expect(matches.length, `Expected ${count} "${title}" toast(s)`).toBeGreaterThanOrEqual(count); - }, - { timeout: 4_000, interval: 16 }, - ); -} - -async function waitForNoToast(title: string): Promise { - await vi.waitFor( - () => { - expect(queryToastTitles().filter((t) => t === title)).toHaveLength(0); - }, - { timeout: 10_000, interval: 50 }, - ); -} - -async function waitForNoToasts(): Promise { - await vi.waitFor( - () => { - expect(queryToastTitles()).toHaveLength(0); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForInitialWsSubscriptions(): Promise { - await vi.waitFor( - () => { - expect( - rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), - ).toBe(true); - expect( - rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForServerConfigSnapshot(): Promise { - await vi.waitFor( - () => { - expect(getServerConfig()).not.toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForServerConfigStreamReady(): Promise { - const previousNotificationId = getServerConfigUpdatedNotification()?.id ?? 0; - for (let attempt = 0; attempt < 20; attempt += 1) { - rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { - version: 1, - type: "settingsUpdated", - payload: { settings: encodeServerSettings(fixture.serverConfig.settings) }, - }); - - try { - await vi.waitFor( - () => { - const notification = getServerConfigUpdatedNotification(); - expect(notification?.id).toBeGreaterThan(previousNotificationId); - expect(notification?.source).toBe("settingsUpdated"); - }, - { timeout: 200, interval: 16 }, - ); - return; - } catch { - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } - - throw new Error("Timed out waiting for the server config stream to deliver updates."); -} - -async function mountApp(): Promise<{ cleanup: () => Promise }> { - const host = document.createElement("div"); - host.style.position = "fixed"; - host.style.inset = "0"; - host.style.width = "100vw"; - host.style.height = "100vh"; - host.style.display = "grid"; - host.style.overflow = "hidden"; - document.body.append(host); - - const router = getRouter( - createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), - ROOT_BASE_PATH, - ); - - const screen = await render( - - - , - { container: host }, - ); - await waitForComposerEditor(); - await waitForToastViewport(); - await waitForInitialWsSubscriptions(); - await waitForWsConnection(); - await waitForServerConfigSnapshot(); - await waitForServerConfigStreamReady(); - await waitForNoToasts(); - - return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -describe("Keybindings update toast", () => { - beforeAll(async () => { - fixture = buildFixture(); - await worker.start({ - onUnhandledRequest: "bypass", - quiet: true, - serviceWorker: { url: "/mockServiceWorker.js" }, - }); - }); - - afterAll(async () => { - await rpcHarness.disconnect(); - await worker.stop(); - }); - - beforeEach(async () => { - await rpcHarness.reset({ - resolveUnary: (request) => resolveWsRpc(request._tag), - getInitialStreamValues: (request) => { - if (request._tag === WS_METHODS.subscribeServerLifecycle) { - return [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: fixture.welcome, - }, - ]; - } - if (request._tag === WS_METHODS.subscribeServerConfig) { - return [ - { - version: 1, - type: "snapshot", - config: encodeServerConfig(fixture.serverConfig), - }, - ]; - } - if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { - return [ - { - kind: "snapshot", - snapshot: toShellSnapshot(fixture.snapshot), - }, - ]; - } - if ( - request._tag === ORCHESTRATION_WS_METHODS.subscribeThread && - request.threadId === THREAD_ID - ) { - return [ - { - kind: "snapshot", - snapshot: { - snapshotSequence: fixture.snapshot.snapshotSequence, - thread: fixture.snapshot.threads[0], - }, - }, - ]; - } - return []; - }, - }); - await __resetLocalApiForTests(); - localStorage.clear(); - document.body.innerHTML = ""; - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - useStore.setState({ - activeEnvironmentId: null, - environmentStateById: {}, - }); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("coalesces rapid consecutive keybinding update toasts with no issues", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([]); - await waitForToast("Keybindings updated", 1); - - // A single edit can produce several reload notifications as the direct update and - // filesystem watcher settle, so avoid stacking identical success toasts. - sendServerConfigUpdatedPush([]); - await new Promise((resolve) => setTimeout(resolve, 250)); - - const titles = queryToastTitles(); - expect(titles.filter((title) => title === "Keybindings updated")).toHaveLength(1); - } finally { - await mounted.cleanup(); - } - }); - - it("shows a warning toast when keybinding config has issues", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([ - { kind: "keybindings.malformed-config", message: "Expected JSON array" }, - ]); - await waitForToast("Invalid keybindings configuration"); - } finally { - await mounted.cleanup(); - } - }); - - it("does not show a toast from the replayed cached value on subscribe", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([]); - await waitForToast("Keybindings updated"); - await waitForNoToast("Keybindings updated"); - - // Remount the app — onServerConfigUpdated replays the cached value - // synchronously on subscribe. This should NOT produce a toast. - await mounted.cleanup(); - const remounted = await mountApp(); - - // Give it a moment to process the replayed value - await new Promise((resolve) => setTimeout(resolve, 500)); - - const titles = queryToastTitles(); - expect( - titles.filter((t) => t === "Keybindings updated").length, - "Replayed cached value should not produce a toast", - ).toBe(0); - - await remounted.cleanup(); - } catch (error) { - await mounted.cleanup().catch(() => {}); - throw error; - } - }); -}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts new file mode 100644 index 00000000000..de5a2123cde --- /dev/null +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts @@ -0,0 +1,73 @@ +import type { ServerConfigStreamEvent } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createKeybindingsUpdateToastController, + KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS, +} from "./KeybindingsUpdateToast.logic"; + +function keybindingsEvent( + overrides: Partial> = {}, +): Extract { + return { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [], + issues: [], + }, + ...overrides, + }; +} + +describe("keybindings update toast policy", () => { + it("coalesces repeated successful reload notifications during the cooldown", () => { + let now = 1_000; + const controller = createKeybindingsUpdateToastController({ + now: () => now, + }); + + expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); + + now += KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS - 1; + expect(controller.handle(keybindingsEvent())).toBeNull(); + + now += 1; + expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); + }); + + it("surfaces keybinding configuration issues", () => { + const controller = createKeybindingsUpdateToastController({}); + + expect( + controller.handle( + keybindingsEvent({ + payload: { + keybindings: [], + issues: [ + { + kind: "keybindings.malformed-config", + message: "Expected JSON array", + }, + ], + }, + }), + ), + ).toEqual({ + _tag: "InvalidConfiguration", + message: "Expected JSON array", + }); + }); + + it("ignores unrelated server config notifications", () => { + const controller = createKeybindingsUpdateToastController({}); + + expect( + controller.handle({ + version: 1, + type: "settingsUpdated", + payload: { settings: {} as never }, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.ts new file mode 100644 index 00000000000..f6a47f50cfc --- /dev/null +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.ts @@ -0,0 +1,45 @@ +import type { ServerConfigStreamEvent } from "@t3tools/contracts"; + +export const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; + +export type KeybindingsUpdateToastDecision = + | { readonly _tag: "Success" } + | { readonly _tag: "InvalidConfiguration"; readonly message: string }; + +export interface KeybindingsUpdateToastController { + readonly handle: (event: ServerConfigStreamEvent | null) => KeybindingsUpdateToastDecision | null; +} + +export function createKeybindingsUpdateToastController(input: { + readonly now?: () => number; +}): KeybindingsUpdateToastController { + const now = input.now ?? Date.now; + let lastSuccessToastAt: number | null = null; + + return { + handle: (event) => { + if (event?.type !== "keybindingsUpdated") { + return null; + } + + const issue = event.payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); + if (issue) { + return { + _tag: "InvalidConfiguration", + message: issue.message, + }; + } + + const currentTime = now(); + if ( + lastSuccessToastAt !== null && + currentTime - lastSuccessToastAt < KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS + ) { + return null; + } + + lastSuccessToastAt = currentTime; + return { _tag: "Success" }; + }, + }; +} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index c874ee58a98..68a5855c1a2 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,7 +1,8 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; -import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; +import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export function NoActiveThreadState() { return ( @@ -9,8 +10,9 @@ export function NoActiveThreadState() {
{isElectron ? ( @@ -19,7 +21,6 @@ export function NoActiveThreadState() { ) : (
- No active thread diff --git a/apps/web/src/components/PlanSidebar.fork.tsx b/apps/web/src/components/PlanSidebar.fork.tsx deleted file mode 100644 index d63818a4636..00000000000 --- a/apps/web/src/components/PlanSidebar.fork.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { memo, useState, useCallback } from "react"; -import type { EnvironmentId, OrchestrationThreadGoal } from "@t3tools/contracts"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; -import { Button } from "./ui/button"; -import ChatMarkdown from "./ChatMarkdown"; -import { - CheckIcon, - ChevronDownIcon, - ChevronRightIcon, - EllipsisIcon, - LoaderIcon, -} from "lucide-react"; -import { cn } from "~/lib/utils"; -import type { ActivePlanState } from "../session-logic"; -import type { LatestProposedPlanState } from "../session-logic"; -import { formatTimestamp } from "../timestampFormat"; -import { - proposedPlanTitle, - buildProposedPlanMarkdownFilename, - normalizePlanMarkdownForExport, - downloadPlanAsTextFile, - stripDisplayedPlanMarkdown, -} from "../proposedPlan"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { readEnvironmentApi } from "~/environmentApi"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { ThreadGoalPanel } from "./ThreadGoalPanel"; -import { ThreadSidebar } from "./ThreadSidebar"; - -function stepStatusIcon(status: string): React.ReactNode { - if (status === "completed") { - return ( - - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - -interface PlanSidebarProps { - activePlan: ActivePlanState | null; - activeProposedPlan: LatestProposedPlanState | null; - activeGoal: OrchestrationThreadGoal | null; - goalCommandDisabled?: boolean | null; - onSubmitGoalCommand?: (command: "/goal pause" | "/goal resume" | "/goal clear") => void; - label?: string; - environmentId: EnvironmentId; - markdownCwd: string | undefined; - workspaceRoot: string | undefined; - timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar"; - onClose: () => void; -} - -const PlanSidebar = memo(function PlanSidebar({ - activePlan, - activeProposedPlan, - activeGoal, - goalCommandDisabled = false, - onSubmitGoalCommand, - label = "Plan", - environmentId, - markdownCwd, - workspaceRoot, - timestampFormat, - mode = "sidebar", - onClose, -}: PlanSidebarProps) { - const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const { copyToClipboard, isCopied } = useCopyToClipboard(); - - const planMarkdown = activeProposedPlan?.planMarkdown ?? null; - const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; - const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - - const handleCopyPlan = useCallback(() => { - if (!planMarkdown) return; - copyToClipboard(planMarkdown); - }, [planMarkdown, copyToClipboard]); - - const handleDownload = useCallback(() => { - if (!planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); - }, [planMarkdown]); - - const handleSaveToWorkspace = useCallback(() => { - const api = readEnvironmentApi(environmentId); - if (!api || !workspaceRoot || !planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - setIsSavingToWorkspace(true); - void api.projects - .writeFile({ - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }) - .then((result) => { - toastManager.add({ - type: "success", - title: "Plan saved", - description: result.relativePath, - }); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }) - .then( - () => setIsSavingToWorkspace(false), - () => setIsSavingToWorkspace(false), - ); - }, [environmentId, planMarkdown, workspaceRoot]); - - const headerMeta = activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null; - - const actions = ( - <> - {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} - - ); - - return ( - - {activeGoal ? ( - - ) : null} - - {/* Explanation */} - {activePlan?.explanation ? ( -

- {activePlan.explanation} -

- ) : null} - - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
-

- Steps -

- {activePlan.steps.map((step) => ( -
- {stepStatusIcon(step.status)} -

- {step.step} -

-
- ))} -
- ) : null} - - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
- - {proposedPlanExpanded ? ( -
- -
- ) : null} -
- ) : null} - - {/* Empty state */} - {!activeGoal && !activePlan && !planMarkdown ? ( -
-

No active plan yet.

-

- Plans will appear here when generated. -

-
- ) : null} -
- ); -}); - -export default PlanSidebar; -export type { PlanSidebarProps }; diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 4c961627fc9..6a157418418 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,8 @@ import { memo, useState, useCallback } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, OrchestrationThreadGoal, ScopedThreadRef } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; @@ -24,10 +28,11 @@ import { stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { readEnvironmentApi } from "~/environmentApi"; +import { projectEnvironment } from "~/state/projects"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { ThreadGoalPanel } from "./ThreadGoalPanel"; +import { useAtomCommand } from "~/state/use-atom-command"; function stepStatusIcon(status: string): React.ReactNode { if (status === "completed") { @@ -82,7 +87,10 @@ const PlanSidebar = memo(function PlanSidebar({ }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { + reportFailure: false, + }); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; @@ -100,24 +108,29 @@ const PlanSidebar = memo(function PlanSidebar({ }, [planMarkdown]); const handleSaveToWorkspace = useCallback(() => { - const api = readEnvironmentApi(environmentId); - if (!api || !workspaceRoot || !planMarkdown) return; + if (!workspaceRoot || !planMarkdown) return; const filename = buildProposedPlanMarkdownFilename(planMarkdown); setIsSavingToWorkspace(true); - void api.projects - .writeFile({ - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }) - .then((result) => { + void (async () => { + const result = await writeProjectFile({ + environmentId, + input: { + cwd: workspaceRoot, + relativePath: filename, + contents: normalizePlanMarkdownForExport(planMarkdown), + }, + }); + setIsSavingToWorkspace(false); + if (result._tag === "Success") { toastManager.add({ type: "success", title: "Plan saved", - description: result.relativePath, + description: result.value.relativePath, }); - }) - .catch((error) => { + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -125,12 +138,9 @@ const PlanSidebar = memo(function PlanSidebar({ description: error instanceof Error ? error.message : "An error occurred.", }), ); - }) - .then( - () => setIsSavingToWorkspace(false), - () => setIsSavingToWorkspace(false), - ); - }, [environmentId, planMarkdown, workspaceRoot]); + } + })(); + }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); return (
(); @@ -8,38 +8,42 @@ const loadedProjectFaviconSrcs = new Set(); export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; - className?: string; + className?: string | undefined; }) { const src = useAssetUrl(input.environmentId, { _tag: "project-favicon", cwd: input.cwd, }); - const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", - ); - useEffect(() => { - setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading"); - }, [src]); if (!src) { - return ( - - ); + return ; } + return ; +} + +function ProjectFaviconFallback({ className }: { readonly className?: string | undefined }) { + return ; +} + +function ProjectFaviconImage({ + src, + className, +}: { + readonly src: string; + readonly className?: string | undefined; +}) { + const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => + loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + ); + return ( <> - {status !== "loaded" ? ( - - ) : null} + {status !== "loaded" ? : null} { loadedProjectFaviconSrcs.add(src); setStatus("loaded"); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index a9c218c0c9e..4438a671f5d 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -3,6 +3,11 @@ import type { ProjectScriptIcon, ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; import { BugIcon, ChevronDownIcon, @@ -91,14 +96,19 @@ export interface NewProjectScriptInput { autoOpenPreview: boolean; } +export type ProjectScriptActionResult = AtomCommandResult; + interface ProjectScriptsControlProps { - scripts: ProjectScript[]; + scripts: ReadonlyArray; keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; - onAddScript: (input: NewProjectScriptInput) => Promise | void; - onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise | void; - onDeleteScript: (scriptId: string) => Promise | void; + onAddScript: (input: NewProjectScriptInput) => Promise; + onUpdateScript: ( + scriptId: string, + input: NewProjectScriptInput, + ) => Promise; + onDeleteScript: (scriptId: string) => Promise; } export default function ProjectScriptsControl({ @@ -161,6 +171,7 @@ export default function ProjectScriptsControl({ } setValidationError(null); + let payload: NewProjectScriptInput; try { const scriptIdForValidation = editingScriptId ?? @@ -173,7 +184,7 @@ export default function ProjectScriptsControl({ command: commandForProjectScript(scriptIdForValidation), }); const trimmedPreviewUrl = previewUrl.trim(); - const payload = { + payload = { name: trimmedName, command: trimmedCommand, icon, @@ -182,16 +193,23 @@ export default function ProjectScriptsControl({ previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, } satisfies NewProjectScriptInput; - if (editingScriptId) { - await onUpdateScript(editingScriptId, payload); - } else { - await onAddScript(payload); - } - setDialogOpen(false); - setIconPickerOpen(false); } catch (error) { setValidationError(error instanceof Error ? error.message : "Failed to save action."); + return; + } + + const result = editingScriptId + ? await onUpdateScript(editingScriptId, payload) + : await onAddScript(payload); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setValidationError(error instanceof Error ? error.message : "Failed to save action."); + } + return; } + setDialogOpen(false); + setIconPickerOpen(false); }; const openAddDialog = () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index aee1ffe9058..8d1f88183fe 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, collectUpdatedProviderSnapshots, - firstRejectedProviderUpdateMessage, + firstFailedProviderUpdateMessage, getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, @@ -246,12 +248,9 @@ describe("provider update launch notification logic", () => { expect( collectUpdatedProviderSnapshots({ results: [ - { - status: "fulfilled", - value: { - providers: [updatedPersonal, currentDefaultSibling], - }, - }, + AsyncResult.success({ + providers: [updatedPersonal, currentDefaultSibling], + }), ], providerInstanceIds: new Set([targetInstanceId]), }), @@ -435,11 +434,9 @@ describe("provider update launch notification logic", () => { }); it("falls back to a rejected RPC message for transport-level failures", () => { - const results: PromiseSettledResult[] = [ - { status: "rejected", reason: new Error("WebSocket closed") }, - ]; + const results = [AsyncResult.failure(Cause.die(new Error("WebSocket closed")))]; - expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed"); + expect(firstFailedProviderUpdateMessage(results)).toBe("WebSocket closed"); expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({ phase: "failed", title: "Provider updates failed", @@ -450,9 +447,7 @@ describe("provider update launch notification logic", () => { it("collects only attempted provider snapshots from update responses", () => { const codex = provider({ driver: driver("codex") }); const cursor = provider({ driver: driver("cursor") }); - const results: PromiseSettledResult<{ readonly providers: ReadonlyArray }>[] = [ - { status: "fulfilled", value: { providers: [codex, cursor] } }, - ]; + const results = [AsyncResult.success({ providers: [codex, cursor] })]; expect( collectUpdatedProviderSnapshots({ diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index f45b2916ce4..3f77974e0fe 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -5,6 +5,10 @@ import { type ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; export type ProviderUpdateCandidate = ServerProvider & { readonly versionAdvisory: NonNullable & { @@ -328,14 +332,14 @@ export function getSingleProviderUpdateProgressToastView( export function collectUpdatedProviderSnapshots(input: { readonly results: ReadonlyArray< - PromiseSettledResult<{ readonly providers: ReadonlyArray }> + AtomCommandResult<{ readonly providers: ReadonlyArray }, unknown> >; readonly providerInstanceIds: ReadonlySet; }): ServerProvider[] { const matchedProviders: ServerProvider[] = []; for (const result of input.results) { - if (result.status !== "fulfilled") { + if (result._tag === "Failure") { continue; } for (const provider of result.value.providers) { @@ -348,14 +352,15 @@ export function collectUpdatedProviderSnapshots(input: { return dedupeProvidersByInstanceId(matchedProviders); } -export function firstRejectedProviderUpdateMessage( - results: ReadonlyArray>, +export function firstFailedProviderUpdateMessage( + results: ReadonlyArray>, ): string | null { - const rejected = results.find((result) => result.status === "rejected"); - if (!rejected) { + const failed = results.find((result) => result._tag === "Failure"); + if (!failed || failed._tag !== "Failure") { return null; } - return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed."; + const error = squashAtomCommandFailure(failed); + return error instanceof Error ? error.message : "Provider update failed."; } function getUpdateFinishedAt(provider: ServerProvider): string | null { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 69cd83bf8dc..56814dba1e6 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -1,17 +1,18 @@ import { useNavigate } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import { DownloadIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; -import { ensureLocalApi } from "../localApi"; +import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; +import { usePrimaryEnvironment } from "../state/environments"; import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; -import { useServerProviders } from "../rpc/serverState"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, collectUpdatedProviderSnapshots, - firstRejectedProviderUpdateMessage, + firstFailedProviderUpdateMessage, getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, @@ -20,6 +21,7 @@ import { type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { useAtomCommand } from "../state/use-atom-command"; const seenProviderUpdateNotificationKeys = new Set(); type ProviderUpdateToastId = ReturnType; @@ -101,7 +103,11 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { export function ProviderUpdateLaunchNotification() { const navigate = useNavigate(); - const providers = useServerProviders(); + const providers = useAtomValue(primaryServerProvidersAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { + reportFailure: false, + }); const activeToastRef = useRef(null); const { dismissedNotificationKeys, dismissNotificationKey } = useDismissedProviderUpdateNotificationKeys(); @@ -185,7 +191,7 @@ export function ProviderUpdateLaunchNotification() { }; const runUpdates = () => { - if (updateStarted || oneClickProviders.length === 0) { + if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { return; } updateStarted = true; @@ -206,24 +212,30 @@ export function ProviderUpdateLaunchNotification() { openSettings, }); - void Promise.allSettled( - oneClickProviders.map(async (provider) => - ensureLocalApi().server.updateProvider({ - provider: provider.driver, - instanceId: provider.instanceId, - }), - ), - ).then((results) => { + void (async () => { + const results = []; + for (const provider of oneClickProviders) { + results.push( + await updateProvider({ + environmentId: primaryEnvironment.environmentId, + input: { + provider: provider.driver, + instanceId: provider.instanceId, + }, + }), + ); + } + const activeUpdateToast = activeToastRef.current; if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { return; } - const rejectedMessage = firstRejectedProviderUpdateMessage(results); - if (rejectedMessage) { + const failedMessage = firstFailedProviderUpdateMessage(results); + if (failedMessage) { updateProviderUpdateToast({ toastId, - view: getProviderUpdateRejectedToastView(providerCount, rejectedMessage), + view: getProviderUpdateRejectedToastView(providerCount, failedMessage), openSettings, }); activeToastRef.current = null; @@ -247,7 +259,7 @@ export function ProviderUpdateLaunchNotification() { if (isTerminalProviderUpdateToastView(view)) { activeToastRef.current = null; } - }); + })(); }; toastId = toastManager.add( @@ -288,11 +300,13 @@ export function ProviderUpdateLaunchNotification() { ); activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; }, [ + updateProvider, dismissNotificationKey, dismissedNotificationKeys, notificationKey, oneClickProviders, openProviderSettings, + primaryEnvironment, updateProviders, ]); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 688ea004f52..4004b4930c2 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -7,10 +8,11 @@ import { usePreparePullRequestThreadAction, usePullRequestResolution, } from "~/lib/sourceControlActions"; -import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { useEnvironmentQuery } from "~/state/query"; +import { vcsEnvironment } from "~/state/vcs"; import { Button } from "./ui/button"; import { Dialog, @@ -52,7 +54,14 @@ export function PullRequestThreadDialog({ { wait: 450 }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); - const { data: gitStatus } = useVcsStatus({ environmentId, cwd }); + const { data: gitStatus } = useEnvironmentQuery( + cwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd }, + }), + ); const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], @@ -60,13 +69,6 @@ export function PullRequestThreadDialog({ const terminology = sourceControlPresentation.terminology; const SourceControlIcon = sourceControlPresentation.Icon; - useEffect(() => { - if (!open) return; - setReference(initialReference ?? ""); - setReferenceDirty(false); - setPreparingMode(null); - }, [initialReference, open]); - useEffect(() => { if (!open) return; const frame = window.requestAnimationFrame(() => { @@ -137,20 +139,23 @@ export function PullRequestThreadDialog({ return; } setPreparingMode(mode); - try { - const result = await preparePullRequestThreadAction.run({ - reference: parsedReference, - mode, - ...(mode === "worktree" ? { threadId } : {}), - }); - await onPrepared({ - branch: result.branch, - worktreePath: result.worktreePath, - }); - onOpenChange(false); - } finally { - setPreparingMode(null); + const result = await preparePullRequestThreadAction.run({ + reference: parsedReference, + mode, + ...(mode === "worktree" ? { threadId } : {}), + }); + setPreparingMode(null); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + preparePullRequestThreadAction.resetError(); + } + return; } + await onPrepared({ + branch: result.value.branch, + worktreePath: result.value.worktreePath, + }); + onOpenChange(false); }, [ cwd, @@ -173,9 +178,7 @@ export function PullRequestThreadDialog({ const errorMessage = validationMessage ?? (resolvedPullRequest === null && pullRequestResolution.error - ? pullRequestResolution.error instanceof Error - ? pullRequestResolution.error.message - : `Failed to resolve ${terminology.singular}.` + ? pullRequestResolution.error : preparePullRequestThreadAction.error instanceof Error ? preparePullRequestThreadAction.error.message : preparePullRequestThreadAction.error diff --git a/apps/web/src/components/Sidebar.dblclick.browser.tsx b/apps/web/src/components/Sidebar.dblclick.browser.tsx deleted file mode 100644 index a4e6baaee9b..00000000000 --- a/apps/web/src/components/Sidebar.dblclick.browser.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import "../index.css"; - -import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; -import { useCallback, useRef, useState } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; -import { cleanup, render } from "vitest-browser-react"; - -import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; -import { DEFAULT_INTERACTION_MODE } from "../types"; -import type { SidebarThreadSummary } from "../types"; -import { SidebarThreadRow } from "./Sidebar"; - -// Double-click-to-rename is a desktop affordance; force the non-mobile path so -// the rename input is reachable regardless of the test browser viewport. -vi.mock("~/hooks/useMediaQuery", () => ({ - useIsMobile: () => false, - useMediaQuery: () => false, -})); - -const THREAD_ID = ThreadId.make("thread-1"); -const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const PROJECT_ID = ProjectId.make("project-1"); -const INITIAL_TITLE = "Original title"; - -const ROW_TESTID = `thread-row-${THREAD_ID}`; -const TITLE_TESTID = `thread-title-${THREAD_ID}`; - -// Spies live at module scope so their call history survives the row's -// re-renders; reset between tests. -const spies = { - handleThreadClick: vi.fn(), - startThreadRename: vi.fn(), - navigateToThread: vi.fn(), - handleMultiSelectContextMenu: vi.fn(async () => {}), - handleThreadContextMenu: vi.fn(async () => {}), - clearSelection: vi.fn(), - commitRename: vi.fn(), - attemptArchiveThread: vi.fn(async () => {}), - openPrLink: vi.fn(), -}; - -function buildThread(title: string): SidebarThreadSummary { - return { - id: THREAD_ID, - environmentId: ENVIRONMENT_ID, - projectId: PROJECT_ID, - title, - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - createdAt: "2024-01-01T00:00:00.000Z", - archivedAt: null, - updatedAt: undefined, - latestTurn: null, - branch: null, - worktreePath: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - goal: null, - }; -} - -// Mirrors the real parent (`SidebarProjectItem`): holds the rename state, wires -// `startThreadRename`, and commits by clearing the rename state and persisting -// the new title back onto the thread so the row re-renders with it. -function Harness() { - const [title, setTitle] = useState(INITIAL_TITLE); - const [renamingThreadKey, setRenamingThreadKey] = useState(null); - const [renamingTitle, setRenamingTitle] = useState(""); - const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); - const renamingInputRef = useRef(null); - const renamingCommittedRef = useRef(false); - const confirmArchiveButtonRefs = useRef(new Map()); - - const startThreadRename = useCallback((threadKey: string, nextTitle: string) => { - spies.startThreadRename(threadKey, nextTitle); - setRenamingThreadKey(threadKey); - setRenamingTitle(nextTitle); - renamingCommittedRef.current = false; - }, []); - - const commitRename = useCallback( - async (threadRef: unknown, newTitle: string, originalTitle: string) => { - spies.commitRename(threadRef, newTitle, originalTitle); - const trimmed = newTitle.trim(); - if (trimmed.length > 0) { - setTitle(trimmed); - } - setRenamingThreadKey(null); - renamingInputRef.current = null; - }, - [], - ); - - const cancelRename = useCallback(() => { - setRenamingThreadKey(null); - renamingInputRef.current = null; - }, []); - - return ( - -
    - -
-
- ); -} - -describe("SidebarThreadRow double-click rename", () => { - beforeEach(() => { - for (const spy of Object.values(spies)) spy.mockClear(); - }); - - afterEach(() => { - cleanup(); - }); - - it("double-clicking a row starts the inline rename, focused with text selected", async () => { - render(); - - await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - - const input = page.getByRole("textbox"); - await expect.element(input).toBeVisible(); - - const element = input.element() as HTMLInputElement; - expect(element.value).toBe(INITIAL_TITLE); - // The existing rename-input ref focuses + selects the whole title. - expect(document.activeElement).toBe(element); - expect(element.selectionStart).toBe(0); - expect(element.selectionEnd).toBe(INITIAL_TITLE.length); - }); - - it("Enter commits the rename and the new title persists on the row", async () => { - render(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - const input = page.getByRole("textbox"); - await expect.element(input).toBeVisible(); - - await userEvent.fill(input, "Renamed thread"); - await userEvent.keyboard("{Enter}"); - - // commitRename was invoked with (threadRef, newTitle, originalTitle). - expect(spies.commitRename).toHaveBeenCalledTimes(1); - expect(spies.commitRename).toHaveBeenCalledWith( - expect.anything(), - "Renamed thread", - INITIAL_TITLE, - ); - - // Input is gone and the row now shows the persisted title. - const title = page.getByTestId(TITLE_TESTID); - await expect.element(title).toBeVisible(); - await expect.element(title).toHaveTextContent("Renamed thread"); - }); - - it("Escape cancels the rename without committing", async () => { - render(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - await expect.element(page.getByRole("textbox")).toBeVisible(); - - await userEvent.keyboard("{Escape}"); - - expect(spies.commitRename).not.toHaveBeenCalled(); - const title = page.getByTestId(TITLE_TESTID); - await expect.element(title).toBeVisible(); - await expect.element(title).toHaveTextContent(INITIAL_TITLE); - }); - - it("double-clicking inside the rename input keeps the edit (does not reset to the title)", async () => { - render(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - const input = page.getByRole("textbox"); - await expect.element(input).toBeVisible(); - - await userEvent.fill(input, "Edited but not committed"); - // Double-clicking inside the input (e.g. to select a word) must not bubble - // to the row and restart the rename, which would wipe the edit. - await userEvent.dblClick(input); - - expect((input.element() as HTMLInputElement).value).toBe("Edited but not committed"); - expect(spies.commitRename).not.toHaveBeenCalled(); - }); - - it("double-clicking the row chrome while already renaming does not restart/reset it", async () => { - render(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - const input = page.getByRole("textbox"); - await expect.element(input).toBeVisible(); - await userEvent.fill(input, "Edited"); - expect(spies.startThreadRename).toHaveBeenCalledTimes(1); - - // Double-click the row element itself (chrome, not the input). - const rowEl = page.getByTestId(ROW_TESTID).element(); - rowEl.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, detail: 2 })); - - // Guard short-circuits: rename is not restarted and the edit is preserved. - expect(spies.startThreadRename).toHaveBeenCalledTimes(1); - expect((input.element() as HTMLInputElement).value).toBe("Edited"); - }); - - it("modifier double-click is multi-select intent and does not start a rename", async () => { - render(); - - await userEvent.keyboard("{Shift>}"); - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - await userEvent.keyboard("{/Shift}"); - - await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); - expect(page.getByRole("textbox").elements()).toHaveLength(0); - }); - - it("single click routes through the navigation handler and does not start a rename", async () => { - render(); - - await userEvent.click(page.getByTestId(ROW_TESTID)); - - expect(spies.handleThreadClick).toHaveBeenCalledTimes(1); - // No rename input: the title span is still shown. - await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); - expect(page.getByRole("textbox").elements()).toHaveLength(0); - }); -}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 731b0674300..40af65fa06e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { ProviderDriverKind } from "@t3tools/contracts"; - import { createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, @@ -16,6 +14,7 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -38,6 +37,44 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("resolveSidebarStageBadgeLabel", () => { + it("returns Nightly for nightly primary server versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.28-nightly.20260616.12", + fallbackStageLabel: "Alpha", + }), + ).toBe("Nightly"); + }); + + it("returns the fallback label for stable primary server versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.27", + fallbackStageLabel: "Alpha", + }), + ).toBe("Alpha"); + }); + + it("returns the fallback label when the primary server version is missing", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: null, + fallbackStageLabel: "Dev", + }), + ).toBe("Dev"); + }); + + it("returns the fallback label for malformed nightly prerelease versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.28-nightly.20260616", + fallbackStageLabel: "Alpha", + }), + ).toBe("Alpha"); + }); +}); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -66,6 +103,20 @@ describe("hasUnseenCompletion", () => { }), ).toBe(true); }); + + it("treats a missing client visit marker as read", () => { + expect( + hasUnseenCompletion({ + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: undefined, + session: null, + }), + ).toBe(false); + }); }); describe("createThreadJumpHintVisibilityController", () => { @@ -225,6 +276,7 @@ describe("resolveSidebarNewThreadSeedContext", () => { branch: "feature/draft", worktreePath: "/repo/.t3/worktrees/draft", envMode: "worktree", + startFromOrigin: true, }, }), ).toEqual({ @@ -266,12 +318,14 @@ describe("resolveSidebarNewThreadSeedContext", () => { branch: "feature/new-draft", worktreePath: "/repo/worktree", envMode: "worktree", + startFromOrigin: true, }, }), ).toEqual({ branch: "feature/new-draft", worktreePath: "/repo/worktree", envMode: "worktree", + startFromOrigin: true, }); }); @@ -346,17 +400,17 @@ describe("orderItemsByPreferredIds", () => { { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-alpha"), - cwd: "/work/alpha", + workspaceRoot: "/work/alpha", }, { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-beta"), - cwd: "/work/beta", + workspaceRoot: "/work/beta", }, { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-gamma"), - cwd: "/work/gamma", + workspaceRoot: "/work/gamma", }, ]; const ordered = orderItemsByPreferredIds({ @@ -365,12 +419,31 @@ describe("orderItemsByPreferredIds", () => { getId: getProjectOrderKey, }); - expect(ordered.map((project) => project.cwd)).toEqual([ + expect(ordered.map((project) => project.workspaceRoot)).toEqual([ "/work/gamma", "/work/alpha", "/work/beta", ]); }); + + it("resolves legacy preference aliases without materializing project state", () => { + const ordered = orderItemsByPreferredIds({ + items: [ + { id: "physical-a", cwd: "/work/a" }, + { id: "physical-b", cwd: "/work/b" }, + { id: "physical-c", cwd: "/work/c" }, + ], + preferredIds: ["legacy:/work/c", "legacy:/work/a"], + getId: (project) => project.id, + getPreferenceIds: (project) => [project.id, `legacy:${project.cwd}`], + }); + + expect(ordered.map((project) => project.id)).toEqual([ + "physical-c", + "physical-a", + "physical-b", + ]); + }); }); describe("resolveAdjacentThreadId", () => { @@ -501,11 +574,14 @@ describe("resolveThreadStatusPill", () => { lastVisitedAt: undefined, goal: null, session: { - provider: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread-1"), status: "running" as const, - createdAt: "2026-03-09T10:00:00.000Z", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, updatedAt: "2026-03-09T10:00:00.000Z", - orchestrationStatus: "running" as const, }, }; @@ -550,14 +626,14 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), ).toMatchObject({ label: "Plan Ready", pulse: false }); }); - it("does not show plan ready after the proposed plan was implemented elsewhere", () => { + it("does not manufacture completed state without a client visit marker", () => { expect( resolveThreadStatusPill({ thread: { @@ -566,11 +642,11 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), - ).toMatchObject({ label: "Completed", pulse: false }); + ).toBeNull(); }); it("shows completed when there is an unseen completion and no active blocker", () => { @@ -584,7 +660,7 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), @@ -722,8 +798,9 @@ function makeProject(overrides: Partial = {}): Project { return { id: ProjectId.make("project-1"), environmentId: localEnvironmentId, - name: "Project", - cwd: "/tmp/project", + title: "Project", + workspaceRoot: "/tmp/project", + repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", @@ -740,7 +817,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: localEnvironmentId, - codexThreadId: null, projectId: ProjectId.make("project-1"), title: "Thread", modelSelection: { @@ -753,14 +829,14 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], - error: null, createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, - turnDiffSummaries: [], + checkpoints: [], activities: [], goal: null, ...overrides, @@ -836,8 +912,8 @@ describe("getFallbackThreadIdAfterDelete", () => { describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { const projects = [ - makeProject({ id: ProjectId.make("project-1"), name: "Older project" }), - makeProject({ id: ProjectId.make("project-2"), name: "Newer project" }), + makeProject({ id: ProjectId.make("project-1"), title: "Older project" }), + makeProject({ id: ProjectId.make("project-2"), title: "Newer project" }), ]; const threads = [ makeThread({ @@ -848,9 +924,10 @@ describe("sortProjectsForSidebar", () => { id: "message-1" as never, role: "user", text: "older project user message", + turnId: null, createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", }, ], }), @@ -863,9 +940,10 @@ describe("sortProjectsForSidebar", () => { id: "message-2" as never, role: "user", text: "newer project user message", + turnId: null, createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", streaming: false, - completedAt: "2026-03-09T10:05:00.000Z", }, ], }), @@ -884,12 +962,12 @@ describe("sortProjectsForSidebar", () => { [ makeProject({ id: ProjectId.make("project-1"), - name: "Older project", + title: "Older project", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ id: ProjectId.make("project-2"), - name: "Newer project", + title: "Newer project", updatedAt: "2026-03-09T10:05:00.000Z", }), ], @@ -908,15 +986,15 @@ describe("sortProjectsForSidebar", () => { [ makeProject({ id: ProjectId.make("project-2"), - name: "Beta", - createdAt: undefined, - updatedAt: undefined, + title: "Beta", + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, }), makeProject({ id: ProjectId.make("project-1"), - name: "Alpha", - createdAt: undefined, - updatedAt: undefined, + title: "Alpha", + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, }), ], [], @@ -931,8 +1009,8 @@ describe("sortProjectsForSidebar", () => { it("preserves manual project ordering", () => { const projects = [ - makeProject({ id: ProjectId.make("project-2"), name: "Second" }), - makeProject({ id: ProjectId.make("project-1"), name: "First" }), + makeProject({ id: ProjectId.make("project-2"), title: "Second" }), + makeProject({ id: ProjectId.make("project-1"), title: "First" }), ]; const sorted = sortProjectsForSidebar(projects, [], "manual"); @@ -948,12 +1026,12 @@ describe("sortProjectsForSidebar", () => { [ makeProject({ id: ProjectId.make("project-1"), - name: "Visible project", + title: "Visible project", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ id: ProjectId.make("project-2"), - name: "Archived-only project", + title: "Archived-only project", updatedAt: "2026-03-09T10:00:00.000Z", }), ], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 41f4e39bb73..4e7614ed551 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -9,6 +9,7 @@ import { import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; +import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -18,7 +19,7 @@ export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; - name: string; + title: string; createdAt?: string | undefined; updatedAt?: string | undefined; }; @@ -64,6 +65,13 @@ export interface ThreadJumpHintVisibilityController { dispose: () => void; } +export function resolveSidebarStageBadgeLabel(input: { + primaryServerVersion: string | null | undefined; + fallbackStageLabel: string; +}): string { + return resolveServerBackedAppStageLabel(input); +} + export function createThreadJumpHintVisibilityController(input: { delayMs: number; onVisibilityChange: (visible: boolean) => void; @@ -148,7 +156,7 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(completedAt)) return false; - if (!thread.lastVisitedAt) return true; + if (!thread.lastVisitedAt) return false; const lastVisitedAt = Date.parse(thread.lastVisitedAt); if (Number.isNaN(lastVisitedAt)) return true; @@ -189,11 +197,13 @@ export function resolveSidebarNewThreadSeedContext(input: { branch: string | null; worktreePath: string | null; envMode: SidebarNewThreadEnvMode; + startFromOrigin: boolean; } | null; }): { branch?: string | null; worktreePath?: string | null; envMode: SidebarNewThreadEnvMode; + startFromOrigin?: boolean; } { if (input.defaultEnvMode === "worktree") { return { @@ -206,6 +216,7 @@ export function resolveSidebarNewThreadSeedContext(input: { branch: input.activeDraftThread.branch, worktreePath: input.activeDraftThread.worktreePath, envMode: input.activeDraftThread.envMode, + startFromOrigin: input.activeDraftThread.startFromOrigin, }; } @@ -226,27 +237,38 @@ export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; getId: (item: TItem) => TId; + getPreferenceIds?: (item: TItem) => readonly TId[]; }): TItem[] { - const { getId, items, preferredIds } = input; + const { getId, getPreferenceIds, items, preferredIds } = input; if (preferredIds.length === 0) { return [...items]; } - const itemsById = new Map(items.map((item) => [getId(item), item] as const)); - const preferredIdSet = new Set(preferredIds); - const emittedPreferredIds = new Set(); - const ordered = preferredIds.flatMap((id) => { - if (emittedPreferredIds.has(id)) { - return []; + const indexesByPreferenceId = new Map(); + for (const [index, item] of items.entries()) { + const preferenceIds = getPreferenceIds?.(item) ?? [getId(item)]; + for (const preferenceId of new Set(preferenceIds)) { + const indexes = indexesByPreferenceId.get(preferenceId); + if (indexes) { + indexes.push(index); + } else { + indexesByPreferenceId.set(preferenceId, [index]); + } } - const item = itemsById.get(id); - if (!item) { + } + + const emittedIndexes = new Set(); + const ordered = preferredIds.flatMap((id) => { + const index = indexesByPreferenceId + .get(id) + ?.find((candidate) => !emittedIndexes.has(candidate)); + if (index === undefined) { return []; } - emittedPreferredIds.add(id); - return [item]; + emittedIndexes.add(index); + return [items[index]!]; }); - const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); + const remaining = items.filter((_, index) => !emittedIndexes.has(index)); return [...ordered, ...remaining]; } @@ -367,7 +389,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "connecting") { + if (thread.session?.status === "starting") { return { label: "Connecting", colorClass: "text-sky-600 dark:text-sky-300/80", @@ -545,6 +567,6 @@ export function sortProjectsForSidebar< const byTimestamp = rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; if (byTimestamp !== 0) return byTimestamp; - return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); + return left.title.localeCompare(right.title) || left.id.localeCompare(right.id); }); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index da4d4647c91..19d95e7148f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -17,8 +17,10 @@ import { resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, + ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; +import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -39,11 +41,11 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, - type DesktopUpdateState, + DEFAULT_SERVER_SETTINGS, ProjectId, type ScopedThreadRef, + type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, - type ThreadEnvMode, ThreadId, } from "@t3tools/contracts"; import { @@ -52,7 +54,13 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -61,23 +69,30 @@ import { type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; -import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { APP_STAGE_LABEL } from "../branding"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform, newCommandId } from "../lib/utils"; +import { isMacPlatform } from "../lib/utils"; import { - selectProjectByRef, - selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRefs, - selectSidebarThreadsAcrossEnvironments, - selectThreadByRef, - useStore, -} from "../store"; + readThreadShell, + useProject, + useProjects, + useServerConfigs, + useThreadShells, + useThreadShellsForProjectRefs, +} from "../state/entities"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; -import { useUiStateStore } from "../uiStateStore"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { useAtomCommand } from "../state/use-atom-command"; +import { previewEnvironment } from "../state/preview"; +import { + legacyProjectCwdPreferenceKey, + resolveProjectExpanded, + useUiStateStore, +} from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -86,15 +101,19 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { useModelPickerOpen } from "../modelPickerOpenState"; +import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { useVcsStatus } from "../lib/vcsStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { retainThreadDetailSubscription } from "../environments/runtime/service"; +import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -159,7 +178,7 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, @@ -168,6 +187,7 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -181,19 +201,14 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; -import { readEnvironmentApi } from "../environmentApi"; -import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; -import { useServerKeybindings } from "../rpc/serverState"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, @@ -202,7 +217,6 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -225,6 +239,11 @@ const PROJECT_GROUPING_MODE_LABELS: Record = const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; +function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + useEnvironmentThread(threadRef.environmentId, threadRef.threadId); + return null; +} + function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -237,10 +256,20 @@ function formatProjectMemberActionLabel( groupedProjectCount: number, ): string { if (groupedProjectCount <= 1) { - return member.name; + return member.title; } - return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; + return member.environmentLabel + ? `${member.environmentLabel} — ${member.workspaceRoot}` + : member.workspaceRoot; +} + +function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { + return [ + project.projectKey, + ...project.memberProjects.map((member) => member.physicalProjectKey), + ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), + ]; } function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { @@ -255,7 +284,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin } function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; + keybindings: ResolvedKeybindingsConfig; platform: string; terminalOpen: boolean; threadJumpCommandByKey: ReadonlyMap< @@ -361,35 +390,60 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr environmentId: thread.environmentId, threadId: thread.id, }); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (s) => s.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; + const remoteEnvLabel = environment?.label ?? null; + const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. - const threadProjectCwd = useStore( + const threadProject = useProject( useMemo( - () => (state: import("../store").AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, + () => scopeProjectRef(thread.environmentId, thread.projectId), [thread.environmentId, thread.projectId], ), ); + const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; - const gitStatus = useVcsStatus({ - environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, - }); + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); const isHighlighted = isActive || isSelected; + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void (async () => { + const result = await openDiscoveredPort({ threadRef, port, openPreview }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open preview", + description: + error instanceof Error ? error.message : "The preview could not be opened.", + }), + ); + })(); + }, + [discoveredPorts, navigateToThread, openPreview, threadRef], + ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ @@ -445,17 +499,6 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [attemptArchiveThread, clearConfirmingArchive, threadRef], ); - const handleOpenDiscoveredPort = useCallback( - (event: React.MouseEvent) => { - const port = discoveredPorts[0]; - if (!port) return; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(threadRef); - void openDiscoveredPort({ threadRef, port }); - }, - [discoveredPorts, navigateToThread, threadRef], - ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { // Already renaming this row: a double-click on the row chrome (outside the @@ -487,20 +530,48 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr event.preventDefault(); const hasSelection = useThreadSelectionStore.getState().hasSelection(); if (hasSelection && isSelected) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); + void (async () => { + const result = await settlePromise(() => + handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); return; } if (hasSelection) { clearSelection(); } - void handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }); + void (async () => { + const result = await settlePromise(() => + handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); }, [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], ); @@ -691,6 +762,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )} + {terminalStatus && ( ["handleNewThread"]; + handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; @@ -1030,27 +1102,34 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec isManualProjectSorting, dragHandleProps, } = props; - const threadSortOrder = useSettings( + const threadSortOrder = useClientSettings( (settings) => settings.sidebarThreadSortOrder, ); - const appSettingsConfirmThreadDelete = useSettings( + const appSettingsConfirmThreadDelete = useClientSettings( (settings) => settings.confirmThreadDelete, ); - const appSettingsConfirmThreadArchive = useSettings( + const appSettingsConfirmThreadArchive = useClientSettings( (settings) => settings.confirmThreadArchive, ); - const defaultThreadEnvMode = useSettings( - (settings) => settings.defaultThreadEnvMode, - ); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); - const { updateSettings } = useUpdateSettings(); - const sidebarThreadPreviewCount = useSettings( + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const serverConfigs = useServerConfigs(); + const deleteProject = useAtomCommand(projectEnvironment.delete, { + reportFailure: false, + }); + const updateProject = useAtomCommand(projectEnvironment.update, { + reportFailure: false, + }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const updateSettings = useUpdateClientSettings(); + const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, ); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); - const toggleProject = useUiStateStore((state) => state.toggleProject); + const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -1096,38 +1175,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); }, }); - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { - event.preventDefault(); - event.stopPropagation(); - - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - }); - return; - } - - void api.shell.openExternal(prUrl).catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open pull request link", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }); - }, []); - const sidebarThreads = useStore( - useShallow( - useMemo( - () => (state: import("../store").AppState) => - selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), - [project.memberProjectRefs], - ), - ), - ); + const openPrLink = useOpenPrLink(); + const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); const sidebarThreadByKey = useMemo( () => new Map( @@ -1144,8 +1193,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; const projectThreads = sidebarThreads; - const projectExpanded = useUiStateStore( - (state) => state.projectExpandedById[project.projectKey] ?? true, + const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); + const projectExpanded = useUiStateStore((state) => + resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), ); const threadLastVisitedAts = useUiStateStore( useShallow((state) => @@ -1231,7 +1281,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads, }; }, [projectThreads, threadLastVisitedAts, threadSortOrder]); - const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1329,15 +1378,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - toggleProject(project.projectKey); + setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, [ clearSelection, dragInProgressRef, - project.projectKey, + projectExpanded, + projectPreferenceKeys, + setProjectExpanded, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, - toggleProject, ], ); @@ -1348,9 +1398,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (dragInProgressRef.current) { return; } - toggleProject(project.projectKey); + setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, - [dragInProgressRef, project.projectKey, toggleProject], + [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -1373,7 +1423,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { setProjectRenameTarget(member); - setProjectRenameTitle(member.name); + setProjectRenameTitle(member.title); }, []); const openProjectGroupingDialog = useCallback( @@ -1388,28 +1438,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const removeProject = useCallback( - async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}): Promise => { + async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + const result = await deleteProject({ + environmentId: member.environmentId, + input: { + projectId: member.id, + ...(options.force === true ? { force: true } : {}), + }, + }); + if (result._tag === "Failure") { + return result; + } const draftStore = useComposerDraftStore.getState(); const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(memberProjectRef); - - const projectApi = readEnvironmentApi(member.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: member.id, - ...(options.force === true ? { force: true } : {}), - }); + return result; }, - [], + [deleteProject], ); const handleRemoveProject = useCallback( @@ -1437,17 +1486,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec window.setTimeout(resolve, 180); }); - const latestProjectThreads = selectSidebarThreadsForProjectRefs( - useStore.getState(), - [memberProjectRef], + const latestProjectThreads = Array.from( + sidebarThreadByKeyRef.current.values(), + ).filter( + (thread) => + thread.environmentId === memberProjectRef.environmentId && + thread.projectId === memberProjectRef.projectId, ); const confirmed = await api.dialogs.confirm( latestProjectThreads.length > 0 ? [ - `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${ + `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ latestProjectThreads.length === 1 ? "" : "s" }?`, - `Path: ${member.cwd}`, + `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), @@ -1456,8 +1508,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec "This action cannot be undone.", ].join("\n") : [ - `Remove project "${member.name}"?`, - `Path: ${member.cwd}`, + `Remove project "${member.title}"?`, + `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), @@ -1468,19 +1520,32 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - await removeProject(member, { force: true }); + const result = await removeProject(member, { force: true }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to remove "${member.title}"`, + description: + error instanceof Error + ? error.message + : "Unknown error removing project.", + }), + ); + } })().catch((error) => { const message = error instanceof Error ? error.message : "Unknown error removing project."; console.error("Failed to remove project", { projectId: member.id, environmentId: member.environmentId, - error, + ...safeErrorLogAttributes(error), }); toastManager.add( stackedThreadToast({ type: "error", - title: `Failed to remove "${member.name}"`, + title: `Failed to remove "${member.title}"`, description: message, }), ); @@ -1493,8 +1558,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } const message = [ - `Remove project "${member.name}"?`, - `Path: ${member.cwd}`, + `Remove project "${member.title}"?`, + `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), "This removes only this project entry.", ].join("\n"); @@ -1503,19 +1568,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - try { - await removeProject(member); - } catch (error) { + const result = await removeProject(member); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); const message = error instanceof Error ? error.message : "Unknown error removing project."; console.error("Failed to remove project", { projectId: member.id, environmentId: member.environmentId, - error, + ...safeErrorLogAttributes(error), }); toastManager.add( stackedThreadToast({ type: "error", - title: `Failed to remove "${member.name}"`, + title: `Failed to remove "${member.title}"`, description: message, }), ); @@ -1551,7 +1616,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec openProjectGroupingDialog(member); return; case "copy-path": - copyPathToClipboard(member.cwd, { path: member.cwd }); + copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot }); return; case "delete": return handleRemoveProject(member); @@ -1744,9 +1809,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec for (const threadKey of threadKeys) { const thread = sidebarThreadByKeyRef.current.get(threadKey); if (!thread) continue; - await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { deletedThreadKeys, }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } } removeFromSelection(threadKeys); }, @@ -1766,7 +1844,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); const currentActiveThread = currentRouteTarget?.kind === "server" - ? (selectThreadByRef(useStore.getState(), currentRouteTarget.threadRef) ?? null) + ? readThreadShell(currentRouteTarget.threadRef) : null; const draftStore = useComposerDraftStore.getState(); const currentActiveDraftThread = @@ -1778,7 +1856,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const seedContext = resolveSidebarNewThreadSeedContext({ projectId: member.id, defaultEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: defaultThreadEnvMode, + defaultEnvMode: + serverConfigs.get(member.environmentId)?.settings.defaultThreadEnvMode ?? + DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, }), activeThread: currentActiveThread && currentActiveThread.projectId === member.id @@ -1795,21 +1875,39 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec branch: currentActiveDraftThread.branch, worktreePath: currentActiveDraftThread.worktreePath, envMode: currentActiveDraftThread.envMode, + startFromOrigin: currentActiveDraftThread.startFromOrigin, } : null, }); if (isMobile) { setOpenMobile(false); } - void handleNewThread(scopeProjectRef(member.environmentId, member.id), { - ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), - ...(seedContext.worktreePath !== undefined - ? { worktreePath: seedContext.worktreePath } - : {}), - envMode: seedContext.envMode, - }); + void (async () => { + const result = await settlePromise(() => + handleNewThread(scopeProjectRef(member.environmentId, member.id), { + ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), + ...(seedContext.worktreePath !== undefined + ? { worktreePath: seedContext.worktreePath } + : {}), + envMode: seedContext.envMode, + ...(seedContext.startFromOrigin !== undefined + ? { startFromOrigin: seedContext.startFromOrigin } + : {}), + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); }, - [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile], + [handleNewThread, isMobile, router, serverConfigs, setOpenMobile], ); const handleCreateThreadClick = useCallback( @@ -1827,16 +1925,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!api) { return; } - const clicked = await api.contextMenu.show( - project.memberProjects.map((member) => ({ - id: member.physicalProjectKey, - label: formatProjectMemberActionLabel(member, project.groupedProjectCount), - })), - { - x: event.clientX, - y: event.clientY, - }, + const clickedResult = await settlePromise(() => + api.contextMenu.show( + project.memberProjects.map((member) => ({ + id: member.physicalProjectKey, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + })), + { + x: event.clientX, + y: event.clientY, + }, + ), ); + if (clickedResult._tag === "Failure") { + const error = squashAtomCommandFailure(clickedResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not choose environment", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + const clicked = clickedResult.value; if (!clicked) { return; } @@ -1854,9 +1966,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const attemptArchiveThread = useCallback( async (threadRef: ScopedThreadRef) => { - try { - await archiveThread(threadRef); - } catch (error) { + const result = await archiveThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1904,19 +2016,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec finishRename(); return; } - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - finishRename(); - return; - } - try { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed, - }); - } catch (error) { + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1927,7 +2035,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } finishRename(); }, - [], + [updateThreadMetadata], ); const closeProjectRenameDialog = useCallback(() => { @@ -1949,32 +2057,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - if (trimmed === projectRenameTarget.name) { + if (trimmed === projectRenameTarget.title) { closeProjectRenameDialog(); return; } - const api = readEnvironmentApi(projectRenameTarget.environmentId); - if (!api) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: "Project API unavailable.", - }), - ); - return; - } - - try { - await api.orchestration.dispatchCommand({ - type: "project.meta.update", - commandId: newCommandId(), + const result = await updateProject({ + environmentId: projectRenameTarget.environmentId, + input: { projectId: projectRenameTarget.id, title: trimmed, - }); + }, + }); + if (result._tag === "Success") { closeProjectRenameDialog(); - } catch (error) { + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1983,7 +2081,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }), ); } - }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]); const closeProjectGroupingDialog = useCallback(() => { setProjectGroupingTarget(null); @@ -2026,7 +2124,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadProject = memberProjectByScopedKey.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ); - const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null; + const threadWorkspacePath = + thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, @@ -2077,7 +2176,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } } - await deleteThread(threadRef); + const result = await deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } }, [ appSettingsConfirmThreadDelete, @@ -2086,7 +2195,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec deleteThread, markThreadUnread, memberProjectByScopedKey, - project.cwd, + project.workspaceRoot, startThreadRename, ], ); @@ -2135,7 +2244,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} @@ -2203,7 +2312,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec showEmptyThreadState={showEmptyThreadState} shouldShowThreadPanel={shouldShowThreadPanel} isThreadListExpanded={isThreadListExpanded} - projectCwd={project.cwd} + projectCwd={project.workspaceRoot} activeRouteThreadKey={activeRouteThreadKey} threadJumpLabelByKey={threadJumpLabelByKey} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} @@ -2243,7 +2352,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec Rename project {projectRenameTarget - ? `Update the title for ${projectRenameTarget.cwd}.` + ? `Update the title for ${projectRenameTarget.workspaceRoot}.` : "Update the project title."} @@ -2290,7 +2399,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec Project grouping {projectGroupingTarget - ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` + ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` : "Choose how this project should be grouped in the sidebar."} @@ -2359,22 +2468,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); -function T3Wordmark() { - return ( - - - - ); -} - type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" @@ -2571,43 +2664,65 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { - const wordmark = ( -
- - - - - - Code - - - {APP_STAGE_LABEL} - - - } - /> - - Version {APP_VERSION} - - -
- ); - return isElectron ? ( - - {wordmark} + + + ) : ( - {wordmark} + + + + ); }); +function SidebarBrand() { + const stageLabel = useSidebarStageLabel(); + + return ( + + + + Code + + + {stageLabel} + + + ); +} + +function useSidebarStageLabel() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +function T3Wordmark() { + return ( + + + + ); +} + const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); const { isMobile, setOpenMobile } = useSidebar(); @@ -2648,7 +2763,7 @@ interface SidebarProjectsContentProps { threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; - updateSettings: ReturnType["updateSettings"]; + updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; @@ -2656,7 +2771,7 @@ interface SidebarProjectsContentProps { handleProjectDragStart: (event: DragStartEvent) => void; handleProjectDragEnd: (event: DragEndEvent) => void; handleProjectDragCancel: (event: DragCancelEvent) => void; - handleNewThread: ReturnType["handleNewThread"]; + handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; sortedProjects: readonly SidebarProjectSnapshot[]; @@ -2909,21 +3024,21 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }); export default function Sidebar() { - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const projects = useProjects(); + const sidebarThreads = useThreadShells(); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); - const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); - const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); - const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); - const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useNewThreadHandler(); + const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const sidebarProjectGroupingMode = useClientSettings((s) => s.sidebarProjectGroupingMode); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const updateSettings = useUpdateClientSettings(); + const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); const routeThreadRef = useParams({ @@ -2931,8 +3046,13 @@ export default function Sidebar() { select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const keybindings = useServerKeybindings(); - const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); @@ -2940,20 +3060,29 @@ export default function Sidebar() { const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); - const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const desktopUpdateState = useDesktopUpdateState(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); - const modelPickerOpen = useModelPickerOpen(); + const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], }); }, [projectOrder, projects]); @@ -2982,19 +3111,9 @@ export default function Sidebar() { projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => { - const rt = savedEnvironmentRuntimeById[environmentId]; - const saved = savedEnvironmentRegistry[environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? null; - }, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }); - }, [ - orderedProjects, - projectGroupingSettings, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [environmentLabelById, orderedProjects, projectGroupingSettings, primaryEnvironmentId]); const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), @@ -3047,15 +3166,10 @@ export default function Sidebar() { const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalUiState( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - modelPickerOpen, + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), }), - [modelPickerOpen, routeThreadRef], + [routeTerminalOpen], ); const newThreadShortcutLabelOptions = useMemo( () => ({ @@ -3118,9 +3232,9 @@ export default function Sidebar() { (member) => member.physicalProjectKey, ); const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); - reorderProjects(activeMemberKeys, overMemberKeys); + reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); }, - [sidebarProjectSortOrder, reorderProjects, sidebarProjects], + [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); const handleProjectDragStart = useCallback( @@ -3201,7 +3315,10 @@ export default function Sidebar() { ), sidebarThreadSortOrder, ); - const projectExpanded = projectExpandedById[project.projectKey] ?? true; + const projectExpanded = resolveProjectExpanded( + projectExpandedById, + projectExpansionPreferenceKeys(project), + ); const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = !projectExpanded && activeThreadKey @@ -3252,19 +3369,11 @@ export default function Sidebar() { () => [...threadJumpCommandByKey.keys()], [threadJumpCommandByKey], ); - const sidebarShortcutContext = useMemo( - () => ({ - terminalFocus: false, - terminalOpen: routeThreadRef - ? selectThreadTerminalUiState( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - modelPickerOpen, - }), - [modelPickerOpen, routeThreadRef], - ); + const sidebarShortcutContext = { + terminalFocus: false, + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }; const threadJumpLabelByKey = useMemo( () => buildThreadJumpLabelMap({ @@ -3300,18 +3409,6 @@ export default function Sidebar() { [prewarmedSidebarThreadKeys], ); - useEffect(() => { - const releases = prewarmedSidebarThreadRefs.map((ref) => - retainThreadDetailSubscription(ref.environmentId, ref.threadId), - ); - - return () => { - for (const release of releases) { - release(); - } - }; - }, [prewarmedSidebarThreadRefs]); - useEffect(() => { updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); @@ -3398,39 +3495,6 @@ export default function Sidebar() { }; }, [clearSelection]); - useEffect(() => { - if (!isElectron) return; - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.getUpdateState !== "function" || - typeof bridge.onUpdateState !== "function" - ) { - return; - } - - let disposed = false; - let receivedSubscriptionUpdate = false; - const unsubscribe = bridge.onUpdateState((nextState) => { - if (disposed) return; - receivedSubscriptionUpdate = true; - setDesktopUpdateState(nextState); - }); - - void bridge - .getUpdateState() - .then((nextState) => { - if (disposed || receivedSubscriptionUpdate) return; - setDesktopUpdateState(nextState); - }) - .catch(() => undefined); - - return () => { - disposed = true; - unsubscribe(); - }; - }, []); - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) @@ -3536,6 +3600,9 @@ export default function Sidebar() { return ( <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( + + ))} {isOnSettings ? ( diff --git a/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx new file mode 100644 index 00000000000..07711ca84b7 --- /dev/null +++ b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from "react"; + +import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; +import { toastManager } from "./ui/toast"; + +function describeSlowRequests(requests: ReadonlyArray): string { + const count = requests.length; + const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); + + return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; +} + +function SlowRequestDetails({ requests }: { requests: ReadonlyArray }) { + return ( +
    + {requests.map((request) => ( +
  • +
    {request.tag}
    +
    + Started {new Date(request.startedAt).toLocaleTimeString()} +
    +
  • + ))} +
+ ); +} + +export function SlowRpcRequestToastCoordinator() { + const slowRequests = useSlowRpcAckRequests(); + const toastIdRef = useRef | null>(null); + + useEffect(() => { + if (slowRequests.length === 0) { + if (toastIdRef.current !== null) { + toastManager.close(toastIdRef.current); + toastIdRef.current = null; + } + return; + } + + const nextToast = { + data: { + expandableContent: , + expandableDescriptionTrigger: true, + expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, + }, + description: describeSlowRequests(slowRequests), + timeout: 0, + title: "Some requests are slow", + type: "warning" as const, + }; + + if (toastIdRef.current === null) { + toastIdRef.current = toastManager.add(nextToast); + } else { + toastManager.update(toastIdRef.current, nextToast); + } + }, [slowRequests]); + + useEffect( + () => () => { + if (toastIdRef.current !== null) { + toastManager.close(toastIdRef.current); + } + }, + [], + ); + + return null; +} diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx new file mode 100644 index 00000000000..868bd2cd99c --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -0,0 +1,39 @@ +import { ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators"; + +describe("ThreadWorktreeIndicator", () => { + it("renders the worktree folder and branch in an accessible label", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('role="img"'); + expect(markup).toContain( + 'aria-label="Worktree: sidebar-indicator (feature/sidebar-indicator)"', + ); + expect(markup).toContain('data-testid="thread-worktree-thread-1"'); + }); + + it.each([null, "", " "])("renders nothing for an absent worktree path", (worktreePath) => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toBe(""); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e70057b01b0..63cfb1095f3 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,20 +1,28 @@ -import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { + scopeProjectRef, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import type { VcsStatusResult } from "@t3tools/contracts"; -import { CloudIcon, GitPullRequestIcon, TargetIcon, TerminalIcon } from "lucide-react"; -import { useMemo } from "react"; -import { usePrimaryEnvironmentId } from "../environments/primary"; import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; -import { useVcsStatus } from "../lib/vcsStatusState"; -import { type AppState, selectProjectByRef, useStore } from "../store"; -import { useThreadRunningTerminalIds } from "../terminalSessionState"; + CloudIcon, + FolderGit2Icon, + GitPullRequestIcon, + TargetIcon, + TerminalIcon, +} from "lucide-react"; +import { useMemo } from "react"; +import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; +import { useProject } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { goalStatusToastTitle } from "../goalPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; import type { SidebarThreadSummary } from "../types"; +import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export interface PrStatusIndicator { @@ -111,6 +119,40 @@ function goalStatusColorClass(status: NonNullable[ } } +export function ThreadWorktreeIndicator({ + thread, +}: { + thread: Pick; +}) { + const worktreePath = thread.worktreePath?.trim(); + if (!worktreePath) { + return null; + } + + const displayPath = formatWorktreePathForDisplay(worktreePath); + const tooltip = thread.branch + ? `Worktree: ${displayPath} (${thread.branch})` + : `Worktree: ${displayPath}`; + + return ( + + + } + > + + + {tooltip} + + ); +} + export function ThreadStatusLabel({ status, compact = false, @@ -172,19 +214,22 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const lastVisitedAt = useUiStateStore( (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], ); - const threadProjectCwd = useStore( + const threadProject = useProject( useMemo( - () => (state: AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, + () => scopeProjectRef(thread.environmentId, thread.projectId), [thread.environmentId, thread.projectId], ), ); + const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd; - const gitStatus = useVcsStatus({ - environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, - }); + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ @@ -247,18 +292,12 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma environmentId: thread.environmentId, threadId: thread.id, }); + const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (state) => state.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; + const remoteEnvLabel = environment?.label ?? null; + const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); if (!terminalStatus && !isRemoteThread) { diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx deleted file mode 100644 index a156c1cf510..00000000000 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ /dev/null @@ -1,428 +0,0 @@ -import "../index.css"; - -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { ProjectId, ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -const { - terminalConstructorSpy, - terminalDisposeSpy, - fitAddonFitSpy, - fitAddonLoadSpy, - environmentApiById, - readEnvironmentApiMock, - readLocalApiMock, -} = vi.hoisted(() => ({ - terminalConstructorSpy: vi.fn(), - terminalDisposeSpy: vi.fn(), - fitAddonFitSpy: vi.fn(), - fitAddonLoadSpy: vi.fn(), - environmentApiById: new Map< - string, - { - terminal: { - open: ReturnType; - attach: ReturnType; - write: ReturnType; - resize: ReturnType; - }; - } - >(), - readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), - readLocalApiMock: vi.fn< - () => - | { - contextMenu: { show: ReturnType }; - shell: { openExternal: ReturnType }; - } - | undefined - >(() => ({ - contextMenu: { show: vi.fn(async () => null) }, - shell: { openExternal: vi.fn(async () => undefined) }, - })), -})); - -vi.mock("@xterm/addon-fit", () => ({ - FitAddon: class MockFitAddon { - fit = fitAddonFitSpy; - }, -})); - -vi.mock("@xterm/xterm", () => ({ - Terminal: class MockTerminal { - cols = 80; - rows = 24; - options: { theme?: unknown } = {}; - buffer = { - active: { - viewportY: 0, - baseY: 0, - getLine: vi.fn(() => null), - }, - }; - - constructor(options: unknown) { - terminalConstructorSpy(options); - } - - loadAddon(addon: unknown) { - fitAddonLoadSpy(addon); - } - - open() {} - - write() {} - - clear() {} - - clearSelection() {} - - focus() {} - - refresh() {} - - scrollToBottom() {} - - hasSelection() { - return false; - } - - getSelection() { - return ""; - } - - getSelectionPosition() { - return null; - } - - attachCustomKeyEventHandler() { - return true; - } - - registerLinkProvider() { - return { dispose: vi.fn() }; - } - - onData() { - return { dispose: vi.fn() }; - } - - onSelectionChange() { - return { dispose: vi.fn() }; - } - - dispose() { - terminalDisposeSpy(); - } - }, -})); - -vi.mock("~/environmentApi", () => ({ - ensureEnvironmentApi: (environmentId: string) => { - const api = readEnvironmentApiMock(environmentId); - if (!api) { - throw new Error(`Environment API not found for ${environmentId}`); - } - return api; - }, - readEnvironmentApi: readEnvironmentApiMock, -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(() => { - throw new Error("ensureLocalApi not implemented in browser test"); - }), - readLocalApi: readLocalApiMock, -})); - -import { TerminalViewport } from "./ThreadTerminalDrawer"; - -const THREAD_ID = ThreadId.make("thread-terminal-browser"); -const PROJECT_ID = ProjectId.make("project-terminal-browser"); - -function createEnvironmentApi() { - const snapshot = { - threadId: THREAD_ID, - terminalId: "term-1", - cwd: "/repo/project", - worktreePath: null, - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "Terminal 1", - updatedAt: "2026-04-07T00:00:00.000Z", - }; - - return { - terminal: { - open: vi.fn(async () => snapshot), - attach: vi.fn( - ( - _input: unknown, - listener: (event: TerminalAttachStreamEvent) => void, - _options?: unknown, - ) => { - listener({ type: "snapshot", snapshot }); - return vi.fn(); - }, - ), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - }, - }; -} - -async function mountTerminalViewport(props: { - threadRef: ReturnType; - drawerBackgroundColor?: string; - drawerTextColor?: string; - runtimeEnv?: Record; -}) { - const drawer = document.createElement("div"); - drawer.className = "thread-terminal-drawer"; - if (props.drawerBackgroundColor) { - drawer.style.backgroundColor = props.drawerBackgroundColor; - } - if (props.drawerTextColor) { - drawer.style.color = props.drawerTextColor; - } - - const host = document.createElement("div"); - host.style.width = "800px"; - host.style.height = "400px"; - drawer.append(host); - document.body.append(drawer); - - const screen = await render( - undefined} - onAddTerminalContext={() => undefined} - focusRequestId={0} - autoFocus={false} - resizeEpoch={0} - drawerHeight={320} - keybindings={[]} - />, - { container: host }, - ); - - return { - rerender: async (nextProps: { - threadRef: ReturnType; - runtimeEnv?: Record; - }) => { - await screen.rerender( - undefined} - onAddTerminalContext={() => undefined} - focusRequestId={0} - autoFocus={false} - resizeEpoch={0} - drawerHeight={320} - keybindings={[]} - />, - ); - }, - cleanup: async () => { - await screen.unmount(); - drawer.remove(); - }, - }; -} - -describe("TerminalViewport", () => { - afterEach(() => { - environmentApiById.clear(); - readEnvironmentApiMock.mockClear(); - readLocalApiMock.mockClear(); - terminalConstructorSpy.mockClear(); - terminalDisposeSpy.mockClear(); - fitAddonFitSpy.mockClear(); - fitAddonLoadSpy.mockClear(); - }); - - it("does not create a terminal when APIs are unavailable", async () => { - readEnvironmentApiMock.mockReturnValueOnce(undefined); - readLocalApiMock.mockReturnValueOnce(undefined); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - }); - - try { - await vi.waitFor(() => { - expect(terminalConstructorSpy).not.toHaveBeenCalled(); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("renders and attaches the terminal without the desktop local API", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); - readLocalApiMock.mockReturnValueOnce(undefined); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - }); - - try { - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); - expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); - fitAddonFitSpy.mockImplementationOnce(() => { - throw new TypeError("Cannot read properties of undefined (reading 'dimensions')"); - }); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - }); - - try { - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); - expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); - expect(fitAddonFitSpy).toHaveBeenCalled(); - } finally { - await mounted.cleanup(); - } - }); - - it("reattaches the terminal when the scoped thread reference changes", async () => { - const environmentA = createEnvironmentApi(); - const environmentB = createEnvironmentApi(); - environmentApiById.set("environment-a", environmentA); - environmentApiById.set("environment-b", environmentB); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - }); - - try { - await vi.waitFor(() => { - expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1); - }); - - await mounted.rerender({ - threadRef: scopeThreadRef("environment-b" as never, THREAD_ID), - }); - - await vi.waitFor(() => { - expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1); - }); - expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); - } finally { - await mounted.cleanup(); - } - }); - - it("does not reattach the terminal when the scoped thread reference values stay the same", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - }); - - try { - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); - - await mounted.rerender({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - }); - - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); - expect(terminalDisposeSpy).not.toHaveBeenCalled(); - } finally { - await mounted.cleanup(); - } - }); - - it("does not reattach when runtime env contents are unchanged but object identity changes", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - runtimeEnv: { PATH: "/usr/bin", T3: "1" }, - }); - - try { - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); - - await mounted.rerender({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - runtimeEnv: { T3: "1", PATH: "/usr/bin" }, - }); - - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); - expect(terminalDisposeSpy).not.toHaveBeenCalled(); - } finally { - await mounted.cleanup(); - } - }); - - it("uses the drawer surface colors for the terminal theme", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - drawerBackgroundColor: "rgb(24, 28, 36)", - drawerTextColor: "rgb(228, 232, 240)", - }); - - try { - await vi.waitFor(() => { - expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); - }); - - expect(terminalConstructorSpy).toHaveBeenCalledWith( - expect.objectContaining({ - theme: expect.objectContaining({ - background: "rgb(24, 28, 36)", - foreground: "rgb(228, 232, 240)", - }), - }), - ); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 19ab2e160e7..2ce548b13c8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,6 +1,10 @@ +import { useAtomValue } from "@effect/atom-react"; import { FitAddon } from "@xterm/addon-fit"; import { - Globe2, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { Plus, SquareSplitHorizontal, SquareSplitVertical, @@ -13,8 +17,6 @@ import { type ProjectId, type ResolvedKeybindingsConfig, type ScopedThreadRef, - type TerminalAttachStreamEvent, - type TerminalSessionSnapshot, type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -22,6 +24,7 @@ import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, + type SetStateAction, useCallback, useEffect, useEffectEvent, @@ -32,7 +35,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { collectWrappedTerminalLinkLine, extractTerminalLinks, @@ -57,12 +60,13 @@ import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; -import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; -import { attachTerminalSession } from "../terminalSessionState"; +import { useAttachedTerminalSession } from "../state/terminalSessions"; +import { serverEnvironment } from "../state/server"; +import { previewEnvironment } from "../state/preview"; +import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; -import { useDiscoveredPorts } from "../portDiscoveryState"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { useAtomCommand } from "../state/use-atom-command"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -91,10 +95,10 @@ function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } -function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void { +function writeTerminalBuffer(terminal: Terminal, buffer: string): void { terminal.write("\u001bc"); - if (snapshot.history.length > 0) { - terminal.write(snapshot.history); + if (buffer.length > 0) { + terminal.write(buffer); } } @@ -326,6 +330,21 @@ export function TerminalViewport({ const terminalRef = useRef(null); const fitAddonRef = useRef(null); const environmentId = threadRef.environmentId; + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const openInPreferredEditor = useOpenInPreferredEditor( + environmentId, + serverConfig?.availableEditors ?? [], + ); + const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target)); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const runTerminalWrite = useAtomCommand(terminalEnvironment.write, { + reportFailure: false, + }); + const runTerminalResize = useAtomCommand(terminalEnvironment.resize, { + reportFailure: false, + }); const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); @@ -341,6 +360,38 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + const terminalSession = useAttachedTerminalSession({ + environmentId, + terminal: { + threadId, + terminalId, + cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }, + }); + const writeTerminal = useEffectEvent((data: string) => + runTerminalWrite({ + environmentId, + input: { threadId, terminalId, data }, + }), + ); + const resizeTerminal = useEffectEvent((cols: number, rows: number) => + runTerminalResize({ + environmentId, + input: { threadId, terminalId, cols, rows }, + }), + ); + const terminalBuffer = terminalSession.buffer; + const terminalError = terminalSession.error; + const terminalStatus = terminalSession.status; + const terminalVersion = terminalSession.version; + const previousSessionRef = useRef({ + buffer: terminalBuffer, + error: terminalError, + status: terminalStatus, + version: terminalVersion, + }); useEffect(() => { keybindingsRef.current = keybindings; @@ -350,10 +401,7 @@ export function TerminalViewport({ const mount = containerRef.current; if (!mount) return; - let disposed = false; - const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api) return; const fitAddon = new FitAddon(); const terminal = new Terminal({ @@ -371,6 +419,12 @@ export function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; + previousSessionRef.current = { + buffer: "", + status: "closed", + error: null, + version: 0, + }; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -472,9 +526,9 @@ export function TerminalViewport({ const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; - try { - await api.terminal.write({ threadId, terminalId, data }); - } catch (error) { + const result = await writeTerminal(data); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); } }; @@ -554,12 +608,15 @@ export function TerminalViewport({ const latestTerminal = terminalRef.current; if (!latestTerminal) return; - if (!localApi) { - writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); - return; - } if (match.kind === "url") { + if (!localApi) { + writeSystemMessage( + latestTerminal, + "Opening links is unavailable in this browser.", + ); + return; + } const fallbackToBrowser = () => { void localApi.shell.openExternal(match.text).catch((error: unknown) => { writeSystemMessage( @@ -568,16 +625,11 @@ export function TerminalViewport({ ); }); }; - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - fallbackToBrowser(); - return; - } void openTerminalLinkInPreview({ url: match.text, position: { x: event.clientX, y: event.clientY }, threadRef, - api, + openPreview, localApi, fallbackToBrowser, }); @@ -585,12 +637,17 @@ export function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void openInPreferredEditor(localApi, target).catch((error) => { + void (async () => { + const result = await openTerminalPath(target); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", ); - }); + })(); }, })), ); @@ -598,14 +655,17 @@ export function TerminalViewport({ }); const inputDisposable = terminal.onData((data) => { - void api.terminal - .write({ threadId, terminalId, data }) - .catch((err) => - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Terminal write failed", - ), + void (async () => { + const result = await writeTerminal(data); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + writeSystemMessage( + terminal, + error instanceof Error ? error.message : "Terminal write failed", ); + })(); }); const selectionDisposable = terminal.onSelectionChange(() => { @@ -651,108 +711,6 @@ export function TerminalViewport({ attributeFilter: ["class", "style"], }); - const applyAttachEvent = (event: TerminalAttachStreamEvent) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) { - return; - } - - if (event.type === "activity") { - return; - } - - if (event.type === "snapshot") { - hasHandledExitRef.current = false; - clearSelectionAction(); - writeTerminalSnapshot(activeTerminal, event.snapshot); - return; - } - - if (event.type === "output") { - activeTerminal.write(event.data); - clearSelectionAction(); - return; - } - - if (event.type === "restarted") { - hasHandledExitRef.current = false; - clearSelectionAction(); - writeTerminalSnapshot(activeTerminal, event.snapshot); - return; - } - - if (event.type === "cleared") { - clearSelectionAction(); - activeTerminal.clear(); - activeTerminal.write("\u001bc"); - return; - } - - if (event.type === "error") { - writeSystemMessage(activeTerminal, event.message); - return; - } - - if (event.type === "closed") { - writeSystemMessage(activeTerminal, "Terminal closed"); - } else { - const details = [ - typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, - typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, - ] - .filter((value): value is string => value !== null) - .join(", "); - writeSystemMessage( - activeTerminal, - details.length > 0 ? `Process exited (${details})` : "Process exited", - ); - } - - if (hasHandledExitRef.current) { - return; - } - hasHandledExitRef.current = true; - window.setTimeout(() => { - if (!hasHandledExitRef.current) { - return; - } - handleSessionExited(); - }, 0); - }; - let unsubscribeAttach: (() => void) | null = null; - const attachTerminal = () => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - fitTerminalSafely(activeFitAddon); - unsubscribeAttach = attachTerminalSession({ - environmentId, - client: api, - terminal: { - threadId, - terminalId, - projectId, - cwd, - ...(worktreePath !== undefined ? { worktreePath } : {}), - cols: activeTerminal.cols, - rows: activeTerminal.rows, - ...(runtimeEnv && Object.keys(runtimeEnv).length > 0 ? { env: runtimeEnv } : {}), - }, - onEvent: (event) => { - if (disposed) return; - applyAttachEvent(event); - }, - onSnapshot: () => { - if (disposed) return; - if (autoFocus) { - window.requestAnimationFrame(() => { - activeTerminal.focus(); - }); - } - }, - }); - }; - const fitTimer = window.setTimeout(() => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; @@ -763,54 +721,11 @@ export function TerminalViewport({ if (wasAtBottom) { activeTerminal.scrollToBottom(); } - void api.terminal - .resize({ - threadId, - terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); + void resizeTerminal(activeTerminal.cols, activeTerminal.rows); }, 30); - attachTerminal(); - let resizeFrame = 0; - const resizeObserver = - typeof ResizeObserver === "undefined" - ? null - : new ResizeObserver(() => { - if (resizeFrame !== 0) return; - resizeFrame = window.requestAnimationFrame(() => { - resizeFrame = 0; - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - fitTerminalSafely(activeFitAddon); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void api.terminal - .resize({ - threadId, - terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); - }); - }); - resizeObserver?.observe(mount); return () => { - disposed = true; - unsubscribeAttach?.(); - unsubscribeAttach = null; window.clearTimeout(fitTimer); - if (resizeFrame !== 0) { - window.cancelAnimationFrame(resizeFrame); - } - resizeObserver?.disconnect(); inputDisposable.dispose(); selectionDisposable.dispose(); terminalLinksDisposable.dispose(); @@ -829,6 +744,65 @@ export function TerminalViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, environmentId, projectId, runtimeEnvKey, terminalId, threadId, worktreePath]); + useEffect(() => { + const terminal = terminalRef.current; + const current = { + buffer: terminalBuffer, + error: terminalError, + status: terminalStatus, + version: terminalVersion, + }; + if (!terminal) { + previousSessionRef.current = current; + return; + } + + const previous = previousSessionRef.current; + if (current.version === previous.version) { + return; + } + + if ( + current.buffer.length >= previous.buffer.length && + current.buffer.startsWith(previous.buffer) + ) { + terminal.write(current.buffer.slice(previous.buffer.length)); + } else { + writeTerminalBuffer(terminal, current.buffer); + } + terminal.clearSelection(); + + if (current.error !== null && current.error !== previous.error) { + writeSystemMessage(terminal, current.error); + } + + if (current.status === "running") { + hasHandledExitRef.current = false; + } else if ( + (current.status === "closed" || current.status === "exited") && + current.status !== previous.status && + !hasHandledExitRef.current + ) { + hasHandledExitRef.current = true; + writeSystemMessage( + terminal, + current.status === "closed" ? "Terminal closed" : "Process exited", + ); + window.setTimeout(() => { + if (hasHandledExitRef.current) { + handleSessionExited(); + } + }, 0); + } + + if (previous.version === 0 && autoFocus) { + window.requestAnimationFrame(() => { + terminal.focus(); + }); + } + previousSessionRef.current = current; + }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + useEffect(() => { if (!autoFocus) return; const terminal = terminalRef.current; @@ -842,24 +816,16 @@ export function TerminalViewport({ }, [autoFocus, focusRequestId]); useEffect(() => { - const api = readEnvironmentApi(environmentId); const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; - if (!api || !terminal || !fitAddon) return; + if (!terminal || !fitAddon) return; const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { fitTerminalSafely(fitAddon); if (wasAtBottom) { terminal.scrollToBottom(); } - void api.terminal - .resize({ - threadId, - terminalId, - cols: terminal.cols, - rows: terminal.rows, - }) - .catch(() => undefined); + void resizeTerminal(terminal.cols, terminal.rows); }); return () => { window.cancelAnimationFrame(frame); @@ -966,10 +932,32 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; - const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + const controlledDrawerHeight = clampDrawerHeight(height); + const [drawerHeightState, setDrawerHeightState] = useState(() => ({ + threadId, + height: controlledDrawerHeight, + })); + const drawerHeight = + drawerHeightState.threadId === threadId ? drawerHeightState.height : controlledDrawerHeight; + const setDrawerHeight = useCallback( + (update: SetStateAction) => { + setDrawerHeightState((current) => { + const currentHeight = + current.threadId === threadId ? current.height : controlledDrawerHeight; + const nextHeight = typeof update === "function" ? update(currentHeight) : update; + return nextHeight === currentHeight && current.threadId === threadId + ? current + : { threadId, height: nextHeight }; + }); + }, + [controlledDrawerHeight, threadId], + ); + const setDrawerHeightFromWindowResize = useEffectEvent((nextHeight: number) => { + setDrawerHeight(nextHeight); + }); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); - const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); + const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); const resizeStateRef = useRef<{ pointerId: number; @@ -1100,17 +1088,6 @@ export default function ThreadTerminalDrawer({ } return next; }, [normalizedTerminalIds, terminalLabelsById]); - const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); - const discoveredPortByTerminalId = useMemo(() => { - const next = new Map(); - for (const port of discoveredPorts) { - if (port.terminal?.threadId !== threadId) continue; - if (!next.has(port.terminal.terminalId)) { - next.set(port.terminal.terminalId, port); - } - } - return next; - }, [discoveredPorts, threadId]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1167,11 +1144,8 @@ export default function ThreadTerminalDrawer({ }, []); useEffect(() => { - const clampedHeight = clampDrawerHeight(height); - setDrawerHeight(clampedHeight); - drawerHeightRef.current = clampedHeight; - lastSyncedHeightRef.current = clampedHeight; - }, [height, threadId]); + lastSyncedHeightRef.current = controlledDrawerHeight; + }, [controlledDrawerHeight, threadId]); const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { if (event.button !== 0) return; @@ -1185,20 +1159,23 @@ export default function ThreadTerminalDrawer({ }; }, []); - const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - event.preventDefault(); - const clampedHeight = clampDrawerHeight( - resizeState.startHeight + (resizeState.startY - event.clientY), - ); - if (clampedHeight === drawerHeightRef.current) { - return; - } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - setDrawerHeight(clampedHeight); - }, []); + const handleResizePointerMove = useCallback( + (event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + event.preventDefault(); + const clampedHeight = clampDrawerHeight( + resizeState.startHeight + (resizeState.startY - event.clientY), + ); + if (clampedHeight === drawerHeightRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerHeightRef.current = clampedHeight; + setDrawerHeight(clampedHeight); + }, + [setDrawerHeight], + ); const handleResizePointerEnd = useCallback( (event: ReactPointerEvent) => { @@ -1226,7 +1203,7 @@ export default function ThreadTerminalDrawer({ const clampedHeight = clampDrawerHeight(drawerHeightRef.current); const changed = clampedHeight !== drawerHeightRef.current; if (changed) { - setDrawerHeight(clampedHeight); + setDrawerHeightFromWindowResize(clampedHeight); drawerHeightRef.current = clampedHeight; } if (!resizeStateRef.current) { @@ -1516,7 +1493,6 @@ export default function ThreadTerminalDrawer({ > {terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; - const discoveredPort = discoveredPortByTerminalId.get(terminalId); const closeTerminalLabel = `Close ${ terminalLabelById.get(terminalId) ?? "terminal" }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; @@ -1542,37 +1518,6 @@ export default function ThreadTerminalDrawer({ {terminalLabelById.get(terminalId) ?? "Terminal"}
- {discoveredPort && ( - - - void openDiscoveredPort({ - threadRef, - port: discoveredPort, - }) - } - aria-label={`Open localhost:${discoveredPort.port}`} - /> - } - > - - - - Open localhost:{discoveredPort.port} - - - )} {normalizedTerminalIds.length > 1 && ( = {}): WsConnectionStatus { - return { - attemptCount: 0, - closeCode: null, - closeReason: null, - connectionLabel: null, - connectedAt: null, - disconnectedAt: null, - hasConnected: false, - lastError: null, - lastErrorAt: null, - nextRetryAt: null, - online: true, - phase: "idle", - reconnectAttemptCount: 0, - reconnectMaxAttempts: 8, - reconnectPhase: "idle", - socketUrl: null, - ...overrides, - }; -} - -describe("WebSocketConnectionSurface.logic", () => { - it("forces reconnect on online when the app was offline", () => { - expect( - shouldAutoReconnect( - makeStatus({ - disconnectedAt: "2026-04-03T20:00:00.000Z", - online: false, - phase: "disconnected", - }), - "online", - ), - ).toBe(true); - }); - - it("forces reconnect on focus only for previously connected disconnected states", () => { - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: true, - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "waiting", - }), - "focus", - ), - ).toBe(true); - - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: false, - online: true, - phase: "disconnected", - reconnectAttemptCount: 1, - reconnectPhase: "waiting", - }), - "focus", - ), - ).toBe(false); - }); - - it("forces reconnect on focus for exhausted reconnect loops", () => { - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: true, - online: true, - phase: "disconnected", - reconnectAttemptCount: 8, - reconnectPhase: "exhausted", - }), - "focus", - ), - ).toBe(true); - }); - - it("restarts a stalled reconnect window after the scheduled retry time passes", () => { - expect( - shouldRestartStalledReconnect( - makeStatus({ - hasConnected: true, - nextRetryAt: "2026-04-03T20:00:01.000Z", - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "waiting", - }), - "2026-04-03T20:00:01.000Z", - ), - ).toBe(true); - - expect( - shouldRestartStalledReconnect( - makeStatus({ - hasConnected: true, - nextRetryAt: "2026-04-03T20:00:01.000Z", - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "attempting", - }), - "2026-04-03T20:00:01.000Z", - ), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx deleted file mode 100644 index b54bd865c8b..00000000000 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; - -import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; -import { - getWsConnectionStatus, - getWsConnectionUiState, - setBrowserOnlineStatus, - type WsConnectionStatus, - type WsConnectionUiState, - useWsConnectionStatus, - WS_RECONNECT_MAX_ATTEMPTS, -} from "../rpc/wsConnectionState"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { getPrimaryEnvironmentConnection } from "../environments/runtime"; - -const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; -type WsAutoReconnectTrigger = "focus" | "online"; - -const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, { - day: "numeric", - hour: "numeric", - minute: "2-digit", - month: "short", - second: "2-digit", -}); - -function formatConnectionMoment(isoDate: string | null): string | null { - if (!isoDate) { - return null; - } - - return connectionTimeFormatter.format(new Date(isoDate)); -} - -function formatRetryCountdown(nextRetryAt: string, nowMs: number): string { - const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs); - return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`; -} - -function describeOfflineToast(): string { - return "WebSocket disconnected. Waiting for network."; -} - -function formatReconnectAttemptLabel(status: WsConnectionStatus): string { - const reconnectAttempt = Math.max( - 1, - Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS), - ); - return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`; -} - -function describeExhaustedToast(): string { - return "Retries exhausted trying to reconnect"; -} - -function getConnectionDisplayName(status: WsConnectionStatus): string { - return status.connectionLabel?.trim() || "T3 Server"; -} - -function buildReconnectTitle(status: WsConnectionStatus): string { - return `Disconnected from ${getConnectionDisplayName(status)}`; -} - -function buildRecoveredTitle(status: WsConnectionStatus): string { - return `Reconnected to ${getConnectionDisplayName(status)}`; -} - -function describeRecoveredToast( - previousDisconnectedAt: string | null, - connectedAt: string | null, -): string { - const reconnectedAtLabel = formatConnectionMoment(connectedAt); - const disconnectedAtLabel = formatConnectionMoment(previousDisconnectedAt); - - if (disconnectedAtLabel && reconnectedAtLabel) { - return `Disconnected at ${disconnectedAtLabel} and reconnected at ${reconnectedAtLabel}.`; - } - - if (reconnectedAtLabel) { - return `Connection restored at ${reconnectedAtLabel}.`; - } - - return "Connection restored."; -} - -function describeSlowRpcAckToast(requests: ReadonlyArray): string { - const count = requests.length; - const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); - - return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; -} - -function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) { - return ( -
    - {requests.map((req) => ( -
  • -
    {req.tag}
    -
    - {req.requestId} -
    -
    - Started {formatConnectionMoment(req.startedAt) ?? req.startedAt} -
    -
  • - ))} -
- ); -} - -export function shouldAutoReconnect( - status: WsConnectionStatus, - trigger: WsAutoReconnectTrigger, -): boolean { - const uiState = getWsConnectionUiState(status); - - if (trigger === "online") { - return ( - uiState === "offline" || - uiState === "reconnecting" || - uiState === "error" || - status.reconnectPhase === "exhausted" - ); - } - - return ( - status.online && - status.hasConnected && - (uiState === "reconnecting" || status.reconnectPhase === "exhausted") - ); -} - -export function shouldRestartStalledReconnect( - status: WsConnectionStatus, - expectedNextRetryAt: string, -): boolean { - return ( - status.reconnectPhase === "waiting" && - status.nextRetryAt === expectedNextRetryAt && - status.online && - status.hasConnected - ); -} - -export function WebSocketConnectionCoordinator() { - const status = useWsConnectionStatus(); - const [nowMs, setNowMs] = useState(() => Date.now()); - const lastForcedReconnectAtRef = useRef(0); - const toastIdRef = useRef | null>(null); - const toastResetTimerRef = useRef(null); - const previousUiStateRef = useRef(getWsConnectionUiState(status)); - const previousDisconnectedAtRef = useRef(status.disconnectedAt); - - const runReconnect = useEffectEvent((showFailureToast: boolean) => { - if (toastResetTimerRef.current !== null) { - window.clearTimeout(toastResetTimerRef.current); - toastResetTimerRef.current = null; - } - lastForcedReconnectAtRef.current = Date.now(); - void getPrimaryEnvironmentConnection() - .reconnect() - .catch((error) => { - if (!showFailureToast) { - console.warn("Automatic WebSocket reconnect failed", { error }); - return; - } - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Reconnect failed", - description: - error instanceof Error ? error.message : "Unable to restart the WebSocket.", - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }), - ); - }); - }); - const syncBrowserOnlineStatus = useEffectEvent(() => { - setBrowserOnlineStatus(navigator.onLine !== false); - }); - const triggerManualReconnect = useEffectEvent(() => { - runReconnect(true); - }); - const triggerAutoReconnect = useEffectEvent((trigger: WsAutoReconnectTrigger) => { - const currentStatus = - trigger === "online" ? setBrowserOnlineStatus(true) : getWsConnectionStatus(); - - if (!shouldAutoReconnect(currentStatus, trigger)) { - return; - } - if (Date.now() - lastForcedReconnectAtRef.current < FORCED_WS_RECONNECT_DEBOUNCE_MS) { - return; - } - - runReconnect(false); - }); - - useEffect(() => { - const handleOnline = () => { - triggerAutoReconnect("online"); - }; - const handleFocus = () => { - triggerAutoReconnect("focus"); - }; - - syncBrowserOnlineStatus(); - window.addEventListener("online", handleOnline); - window.addEventListener("offline", syncBrowserOnlineStatus); - window.addEventListener("focus", handleFocus); - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", syncBrowserOnlineStatus); - window.removeEventListener("focus", handleFocus); - }; - }, []); - - useEffect(() => { - if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) { - return; - } - - setNowMs(Date.now()); - const intervalId = window.setInterval(() => { - setNowMs(Date.now()); - }, 1_000); - - return () => { - window.clearInterval(intervalId); - }; - }, [status.nextRetryAt, status.reconnectPhase]); - - useEffect(() => { - if ( - status.reconnectPhase !== "waiting" || - status.nextRetryAt === null || - !status.online || - !status.hasConnected - ) { - return; - } - - const nextRetryAt = status.nextRetryAt; - const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; - const timeoutId = window.setTimeout(() => { - const currentStatus = getWsConnectionStatus(); - if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) { - return; - } - - runReconnect(false); - }, timeoutMs); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [ - status.hasConnected, - status.nextRetryAt, - status.online, - status.reconnectAttemptCount, - status.reconnectPhase, - ]); - - useEffect(() => { - const uiState = getWsConnectionUiState(status); - const previousUiState = previousUiStateRef.current; - const previousDisconnectedAt = previousDisconnectedAtRef.current; - const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting"; - const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null; - const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted"; - - if ( - toastResetTimerRef.current !== null && - (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) - ) { - window.clearTimeout(toastResetTimerRef.current); - toastResetTimerRef.current = null; - } - - if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { - const toastPayload = shouldShowOfflineToast - ? stackedThreadToast({ - data: { - hideCopyButton: true, - }, - description: describeOfflineToast(), - timeout: 0, - title: "Offline", - type: "warning", - }) - : shouldShowExhaustedToast - ? stackedThreadToast({ - actionProps: { - children: "Retry", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: describeExhaustedToast(), - timeout: 0, - title: buildReconnectTitle(status), - type: "error", - }) - : stackedThreadToast({ - actionProps: { - children: "Retry now", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: - status.nextRetryAt === null - ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` - : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, - timeout: 0, - title: buildReconnectTitle(status), - type: "loading", - }); - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, toastPayload); - } else { - toastIdRef.current = toastManager.add(toastPayload); - } - } else if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - - if ( - uiState === "connected" && - (previousUiState === "offline" || previousUiState === "reconnecting") && - previousDisconnectedAt !== null - ) { - const successToast = { - description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), - title: buildRecoveredTitle(status), - type: "success" as const, - timeout: 0, - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }; - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, successToast); - } else { - toastIdRef.current = toastManager.add(successToast); - } - - toastResetTimerRef.current = window.setTimeout(() => { - toastIdRef.current = null; - toastResetTimerRef.current = null; - }, 8_250); - } - - previousUiStateRef.current = uiState; - previousDisconnectedAtRef.current = status.disconnectedAt; - }, [nowMs, status]); - - useEffect(() => { - return () => { - if (toastResetTimerRef.current !== null) { - window.clearTimeout(toastResetTimerRef.current); - } - }; - }, []); - - return null; -} - -export function SlowRpcAckToastCoordinator() { - const slowRequests = useSlowRpcAckRequests(); - const status = useWsConnectionStatus(); - const toastIdRef = useRef | null>(null); - - useEffect(() => { - if (getWsConnectionUiState(status) !== "connected") { - if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - return; - } - - if (slowRequests.length === 0) { - if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - return; - } - - const nextToast = { - data: { - expandableContent: , - expandableDescriptionTrigger: true, - expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, - }, - description: describeSlowRpcAckToast(slowRequests), - timeout: 0, - title: "Some requests are slow", - type: "warning" as const, - }; - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, nextToast); - } else { - toastIdRef.current = toastManager.add(nextToast); - } - }, [slowRequests, status]); - - return null; -} - -export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { - return children; -} diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index bf0dbd9388b..fbebdb539d8 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -1,8 +1,9 @@ import type { AuthSessionState } from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; -import { addSavedEnvironment } from "../../environments/runtime"; +import { connectPairing } from "../../connection/onboarding"; import { peekPairingTokenFromUrl, stripPairingTokenFromUrl, @@ -12,6 +13,7 @@ import { readHostedPairingRequest } from "../../hostedPairing"; import { BASE_PATH } from "../../basePath"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { useAtomCommand } from "../../state/use-atom-command"; export function PairingPendingSurface() { return ( @@ -163,6 +165,9 @@ export function PairingRouteSurface({ } export function HostedPairingRouteSurface() { + const connectPairingEnvironment = useAtomCommand(connectPairing, { + reportFailure: false, + }); const hostedPairingRequestRef = useRef(readHostedPairingRequest()); const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => hostedPairingRequestRef.current ? "pairing" : "error", @@ -198,23 +203,23 @@ export function HostedPairingRouteSurface() { setCanRetry(false); tokenSubmittedRef.current = true; - try { - const record = await addSavedEnvironment({ - label: request.label, - host: request.host, - pairingCode: request.token, - }); + const result = await connectPairingEnvironment({ + host: request.host, + pairingCode: request.token, + }); + if (result._tag === "Success") { setStatus("paired"); - setMessage(`${record.label} is saved in this browser.`); - } catch (error) { - tokenSubmittedRef.current = false; - setStatus("error"); - setCanRetry(true); - setMessage( - `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`, - ); + setMessage(`${request.label || "The environment"} is saved in this browser.`); + return; } - }, []); + + tokenSubmittedRef.current = false; + setStatus("error"); + setCanRetry(true); + setMessage( + `${errorMessageFromUnknown(squashAtomCommandFailure(result))} If the backend accepted this one-time token, request a new pairing link before retrying.`, + ); + }, [connectPairingEnvironment]); useEffect(() => { if (submitAttemptedRef.current) { diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index 1eca82dbd9b..c371acdb362 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -9,8 +9,8 @@ describe("ChangedFilesTree", () => { { name: "a compacted single-chain directory", files: [ - { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, + { path: "apps/web/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/web/src/main.ts", kind: "modified", additions: 3, deletions: 0 }, ], visibleLabels: ["apps/web/src"], hiddenLabels: ["index.ts", "main.ts"], @@ -18,8 +18,18 @@ describe("ChangedFilesTree", () => { { name: "a branch point after a compacted prefix", files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, - { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, + { + path: "apps/server/src/git/Layers/GitCore.ts", + kind: "modified", + additions: 4, + deletions: 3, + }, + { + path: "apps/server/src/provider/Layers/CodexAdapter.ts", + kind: "modified", + additions: 7, + deletions: 2, + }, ], visibleLabels: ["apps/server/src"], hiddenLabels: ["git", "provider", "GitCore.ts", "CodexAdapter.ts"], @@ -27,9 +37,14 @@ describe("ChangedFilesTree", () => { { name: "mixed root files and nested compacted directories", files: [ - { path: "README.md", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 8, deletions: 2 }, + { + path: "packages/contracts/src/orchestration.ts", + kind: "modified", + additions: 13, + deletions: 3, + }, ], visibleLabels: ["README.md", "packages"], hiddenLabels: ["shared/src", "contracts/src", "git.ts", "orchestration.ts"], @@ -60,16 +75,26 @@ describe("ChangedFilesTree", () => { { name: "a compacted single-chain directory", files: [ - { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, + { path: "apps/web/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/web/src/main.ts", kind: "modified", additions: 3, deletions: 0 }, ], visibleLabels: ["apps/web/src", "index.ts", "main.ts"], }, { name: "a branch point after a compacted prefix", files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, - { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, + { + path: "apps/server/src/git/Layers/GitCore.ts", + kind: "modified", + additions: 4, + deletions: 3, + }, + { + path: "apps/server/src/provider/Layers/CodexAdapter.ts", + kind: "modified", + additions: 7, + deletions: 2, + }, ], visibleLabels: [ "apps/server/src", @@ -82,9 +107,14 @@ describe("ChangedFilesTree", () => { { name: "mixed root files and nested compacted directories", files: [ - { path: "README.md", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 8, deletions: 2 }, + { + path: "packages/contracts/src/orchestration.ts", + kind: "modified", + additions: 13, + deletions: 3, + }, ], visibleLabels: [ "README.md", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d840f916ab8..80985f91599 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -18,6 +18,10 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -74,6 +78,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { + getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -101,6 +106,7 @@ import { import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; import { + applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, @@ -443,7 +449,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; } | null; // Pending approvals / inputs @@ -499,10 +505,6 @@ export interface ChatComposerProps { composerElementContextsRef: React.RefObject; composerRef: React.RefObject; - // Scroll - shouldAutoScrollRef: React.RefObject; - scheduleStickToBottom: () => void; - // Callbacks onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; @@ -510,7 +512,7 @@ export interface ChatComposerProps { onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; onAdvanceActivePendingUserInput: () => void; onPreviousActivePendingUserInputQuestion: () => void; @@ -589,8 +591,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerImagesRef, composerTerminalContextsRef, composerElementContextsRef, - shouldAutoScrollRef, - scheduleStickToBottom, onSend, onInterrupt, onImplementPlanInNewThread, @@ -660,8 +660,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. const providerInstanceEntries = useMemo>( - () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), - [providerStatuses], + () => + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + ), + [providerStatuses, settings], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = @@ -785,18 +788,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [selectedProviderEntry], ); + const composerPromptInjectionState = useMemo( + () => getComposerPromptInjectionState(prompt), + [prompt], + ); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, models: selectedProviderModels, - prompt, + promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], }), [ composerModelOptions, - prompt, + composerPromptInjectionState, selectedInstanceId, selectedModel, selectedProvider, @@ -885,7 +892,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); - const composerFormHeightRef = useRef(0); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -1339,15 +1345,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; }; - composerFormHeightRef.current = composerForm.getBoundingClientRect().height; const initialCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); setIsComposerFooterCompact(initialCompactness.footerCompact); if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver((entries) => { - const [entry] = entries; - if (!entry) return; + const observer = new ResizeObserver(() => { const nextCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact((previous) => previous === nextCompactness.primaryActionsCompact @@ -1357,25 +1360,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerFooterCompact((previous) => previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, ); - const nextHeight = entry.contentRect.height; - const previousHeight = composerFormHeightRef.current; - composerFormHeightRef.current = nextHeight; - if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); }); observer.observe(composerForm); return () => { observer.disconnect(); }; - }, [ - activeThreadId, - composerFooterActionLayoutKey, - composerFooterHasWideActions, - scheduleStickToBottom, - shouldAutoScrollRef, - ]); + }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]); // ------------------------------------------------------------------ // Image persist effect @@ -2111,8 +2102,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ref={composerSurfaceRef} data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} className={cn( - "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", - isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", + "chat-composer-glass rounded-[20px] border transition-colors duration-200 has-focus-visible:border-ring/45", + isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border", environmentUnavailable ? "opacity-75" : null, composerProviderState.composerSurfaceClassName, )} @@ -2460,11 +2451,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : environmentUnavailable - ? `${environmentUnavailable.label} is ${ - environmentUnavailable.connectionState === "connecting" - ? "connecting" - : "disconnected" - }` + ? `${environmentUnavailable.label}: ${connectionStatusText( + environmentUnavailable.connection, + )}` : phase === "disconnected" ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 2bfc204cec7..ef3ec863d0b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -5,15 +5,17 @@ import { type ResolvedKeybindingsConfig, type ThreadId, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; -import { SidebarTrigger } from "../ui/sidebar"; +import ProjectScriptsControl, { + type NewProjectScriptInput, + type ProjectScriptActionResult, +} from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; -import { usePrimaryEnvironmentId } from "../../environments/primary/context"; +import { usePrimaryEnvironmentId } from "../../state/environments"; import { cn } from "~/lib/utils"; interface ChatHeaderProps { @@ -23,16 +25,19 @@ interface ChatHeaderProps { activeThreadTitle: string; activeProjectName: string | undefined; openInCwd: string | null; - activeProjectScripts: ProjectScript[] | undefined; + activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; + rightPanelOpen: boolean; gitCwd: string | null; onRunProjectScript: (script: ProjectScript) => void; - onAddProjectScript: (input: NewProjectScriptInput) => Promise; - onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; - onDeleteProjectScript: (scriptId: string) => Promise; - rightPanelOpen: boolean; + onAddProjectScript: (input: NewProjectScriptInput) => Promise; + onUpdateProjectScript: ( + scriptId: string, + input: NewProjectScriptInput, + ) => Promise; + onDeleteProjectScript: (scriptId: string) => Promise; } export function shouldShowOpenInPicker(input: { @@ -58,12 +63,12 @@ export const ChatHeader = memo(function ChatHeader({ preferredScriptId, keybindings, availableEditors, + rightPanelOpen, gitCwd, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, - rightPanelOpen, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const showOpenInPicker = shouldShowOpenInPicker({ @@ -74,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({ return (
- , - promptInjectedValues?: ReadonlyArray, -) { - return { - id, - label, - type: "select" as const, - options: [...options], - ...(options.find((option) => option.isDefault)?.id - ? { currentValue: options.find((option) => option.isDefault)?.id } - : {}), - ...(promptInjectedValues && promptInjectedValues.length > 0 - ? { promptInjectedValues: [...promptInjectedValues] } - : {}), - }; -} - -function booleanDescriptor(id: string, label: string) { - return { - id, - label, - type: "boolean" as const, - }; -} - -async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { - const threadId = ThreadId.make("thread-compact-menu"); - const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); - const threadKey = scopedThreadKey(threadRef); - const provider = ProviderDriverKind.make("claudeAgent"); - const instanceId = ProviderInstanceId.make(props?.modelSelection?.instanceId ?? provider); - const model = - props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; - - useComposerDraftStore.setState({ - draftsByThreadKey: { - // Compose from the canonical empty-draft factory so adding a new - // ComposerThreadDraftState slice (e.g. a future attachment kind) doesn't - // silently break this stub via `Property X is missing in type ...`. - [threadKey]: { - ...createEmptyThreadDraft(), - prompt: props?.prompt ?? "", - modelSelectionByProvider: { - [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), - }, - activeProvider: instanceId, - }, - }, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - const host = document.createElement("div"); - document.body.append(host); - const onPromptChange = vi.fn(); - const providerOptions = props?.modelSelection?.options; - const models = [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor( - "effort", - "Reasoning", - [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High", isDefault: true }, - { id: "max", label: "Max" }, - { id: "ultrathink", label: "Ultrathink" }, - ], - ["ultrathink"], - ), - booleanDescriptor("fastMode", "Fast Mode"), - ], - }), - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [booleanDescriptor("thinking", "Thinking")], - }), - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor( - "effort", - "Reasoning", - [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High", isDefault: true }, - { id: "ultrathink", label: "Ultrathink" }, - ], - ["ultrathink"], - ), - ], - }), - }, - ]; - const screen = await render( - - } - onToggleInteractionMode={vi.fn()} - onTogglePlanSidebar={vi.fn()} - onRuntimeModeChange={vi.fn()} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("CompactComposerControlsMenu", () => { - afterEach(() => { - document.body.innerHTML = ""; - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows fast mode controls for Opus", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - ), - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("On"); - expect(text).toContain("Off"); - }); - }); - - it("hides fast mode controls for non-Opus Claude models", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", - ), - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").not.toContain("Fast Mode"); - }); - }); - - it("shows only the provided effort options", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", - ), - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); - expect(text).not.toContain("Max"); - expect(text).toContain("Ultrathink"); - }); - }); - - it("shows a Claude thinking on/off section for Haiku", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-haiku-4-5", - [{ id: "thinking", value: true }], - ), - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Thinking"); - expect(text).toContain("On"); - expect(text).toContain("Off"); - }); - }); - - it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "effort", value: "high" }], - ), - prompt: "Ultrathink:\nInvestigate this", - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Reasoning"); - expect(text).not.toContain("ultrathink"); - }); - }); - - it("warns when ultrathink appears in prompt body text", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "effort", value: "high" }], - ), - prompt: "Ultrathink:\nplease ultrathink about this problem", - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain( - 'Your prompt contains "ultrathink" in the text. Remove it to change this option.', - ); - }); - }); - - it("can hide the interaction mode section", async () => { - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { container: host }, - ); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).not.toContain("Mode"); - expect(text).not.toContain("Chat"); - expect(text).not.toContain("Plan"); - expect(text).toContain("Access"); - expect(text).toContain("Supervised"); - expect(text).toContain("Full access"); - }); - - await screen.unmount(); - host.remove(); - }); -}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 9901237fdf0..4a2a8f29dfc 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -40,14 +40,12 @@ interface ComposerBannerStackProps { } export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { - const [exitingItemId, setExitingItemId] = useState(null); + const [requestedExitingItemId, setExitingItemId] = useState(null); const dismissTimeoutRef = useRef | null>(null); - - useEffect(() => { - if (exitingItemId && !items.some((item) => item.id === exitingItemId)) { - setExitingItemId(null); - } - }, [exitingItemId, items]); + const exitingItemId = + requestedExitingItemId !== null && items.some((item) => item.id === requestedExitingItemId) + ? requestedExitingItemId + : null; useEffect(() => { return () => { diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx index 5786bab478b..64c3acc7bf7 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -8,7 +8,7 @@ interface ComposerPendingApprovalActionsProps { onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; } export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ diff --git a/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx b/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx deleted file mode 100644 index a5aa6e224e8..00000000000 --- a/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import "../../index.css"; - -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; -import { render } from "vitest-browser-react"; - -import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; - -describe("ComposerPendingReviewComments", () => { - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("renders a removable file comment pill", async () => { - const onRemove = vi.fn(); - const screen = await render( - , - ); - - await expect.element(page.getByText("src/app.ts L2 to L3")).toBeVisible(); - await page.getByRole("button", { name: "Remove comment on src/app.ts L2 to L3" }).click(); - expect(onRemove).toHaveBeenCalledWith("comment-1"); - - await screen.unmount(); - }); -}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index bf869d25c66..d826eca0063 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -208,19 +208,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ); return ( -
{ - if (isResponding) return; handleOptionSelection(activeQuestion.id, option.label); }} className={className} > {content} -
+ ); })}
diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 10031b48cde..fd14c68b0c4 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -9,24 +9,14 @@ interface ExpandedImageDialogProps { } export const ExpandedImageDialog = memo(function ExpandedImageDialog({ - preview: initialPreview, + preview, onClose, }: ExpandedImageDialogProps) { - const [preview, setPreview] = useState(initialPreview); - - // Sync when the parent hands us a new preview reference. - useEffect(() => { - setPreview(initialPreview); - }, [initialPreview]); + const [imageOffset, setImageOffset] = useState(0); + const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; const navigateImage = useCallback((direction: -1 | 1) => { - setPreview((existing) => { - if (existing.images.length <= 1) return existing; - const nextIndex = - (existing.index + direction + existing.images.length) % existing.images.length; - if (nextIndex === existing.index) return existing; - return { ...existing, index: nextIndex }; - }); + setImageOffset((current) => current + direction); }, []); useEffect(() => { @@ -53,7 +43,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ return () => window.removeEventListener("keydown", onKeyDown); }, [navigateImage, onClose, preview.images.length]); - const item = preview.images[preview.index]; + const item = preview.images[index]; if (!item) return null; return ( @@ -100,7 +90,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ />

{item.name} - {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} + {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}

{preview.images.length > 1 && ( diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx deleted file mode 100644 index 3afa0852402..00000000000 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import "../../index.css"; - -import { EnvironmentId } from "@t3tools/contracts"; -import { createRef } from "react"; -import type { LegendListRef } from "@legendapp/list/react"; -import { page } from "vite-plus/test/browser"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -const scrollToEndSpy = vi.fn(); -const getStateSpy = vi.fn(() => ({ isAtEnd: true })); - -vi.mock("@legendapp/list/react", async () => { - const React = await import("react"); - - function LegendList(props: { - data: Array<{ id: string }>; - keyExtractor: (item: { id: string }) => string; - renderItem: (args: { item: { id: string } }) => React.ReactNode; - ListHeaderComponent?: React.ReactNode; - ListFooterComponent?: React.ReactNode; - ref?: React.Ref; - }) { - React.useImperativeHandle( - props.ref, - () => - ({ - scrollToEnd: scrollToEndSpy, - getState: getStateSpy, - }) as unknown as LegendListRef, - ); - - return ( -
- {props.ListHeaderComponent} - {props.data.map((item) => ( -
{props.renderItem({ item })}
- ))} - {props.ListFooterComponent} -
- ); - } - - return { LegendList }; -}); - -import { MessagesTimeline } from "./MessagesTimeline"; - -const MESSAGE_CREATED_AT = "2026-04-13T12:00:00.000Z"; - -function buildProps() { - return { - isWorking: false, - activeTurnInProgress: false, - activeTurnStartedAt: null, - listRef: createRef(), - latestTurn: null, - turnDiffSummaryByAssistantMessageId: new Map(), - routeThreadKey: "environment-local:thread-1", - onOpenTurnDiff: vi.fn(), - revertTurnCountByUserMessageId: new Map(), - onRevertUserMessage: vi.fn(), - isRevertingCheckpoint: false, - onImageExpand: vi.fn(), - activeThreadEnvironmentId: EnvironmentId.make("environment-local"), - markdownCwd: undefined, - resolvedTheme: "dark" as const, - timestampFormat: "24-hour" as const, - workspaceRoot: undefined, - onIsAtEndChange: vi.fn(), - }; -} - -function buildLongUserMessageText(tail = "deep hidden detail only after expand") { - return Array.from({ length: 9 }, (_, index) => - index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`, - ).join("\n"); -} - -function buildUserTimelineEntry(text: string) { - return { - id: "entry-1", - kind: "message" as const, - createdAt: MESSAGE_CREATED_AT, - message: { - id: "message-1" as never, - role: "user" as const, - text, - createdAt: MESSAGE_CREATED_AT, - streaming: false, - }, - }; -} - -function buildAssistantTimelineEntry(text: string) { - return { - id: "entry-assistant-1", - kind: "message" as const, - createdAt: MESSAGE_CREATED_AT, - message: { - id: "message-assistant-1" as never, - role: "assistant" as const, - text, - createdAt: MESSAGE_CREATED_AT, - streaming: false, - }, - }; -} - -describe("MessagesTimeline", () => { - afterEach(() => { - scrollToEndSpy.mockReset(); - getStateSpy.mockClear(); - vi.restoreAllMocks(); - document.body.innerHTML = ""; - }); - - it("renders activity rows instead of the empty placeholder when a thread has non-message timeline data", async () => { - const screen = await render( - , - ); - - try { - await expect - .element(page.getByText("Send a message to start the conversation.")) - .not.toBeInTheDocument(); - await expect.element(page.getByText("Inspecting repository state")).toBeVisible(); - expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull(); - } finally { - await screen.unmount(); - } - }); - - it("uses accessible expansion instead of native titles or preview tooltips for work entry details", async () => { - const screen = await render( - , - ); - - try { - expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull(); - - const commandTrigger = page.getByLabelText( - "Command - git diff -- apps/web/src/components/ChatMarkdown.tsx", - ); - await commandTrigger.hover(); - expect(document.querySelector('[data-slot="tooltip-popup"]')).toBeNull(); - - await commandTrigger.click(); - await expect - .element(page.getByText("git diff -- apps/web/src/components/ChatMarkdown.tsx --stat")) - .toBeVisible(); - } finally { - await screen.unmount(); - } - }); - - it("snaps to the bottom when timeline rows appear after an initially empty render", async () => { - const requestAnimationFrameSpy = vi - .spyOn(window, "requestAnimationFrame") - .mockImplementation((callback: FrameRequestCallback) => { - callback(0); - return 1; - }); - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); - - const props = buildProps(); - const screen = await render(); - - try { - await expect - .element(page.getByText("Send a message to start the conversation.")) - .toBeVisible(); - - await screen.rerender( - , - ); - - await expect.element(page.getByText("Inspecting repository state")).toBeVisible(); - expect(props.onIsAtEndChange).toHaveBeenCalledWith(true); - expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false }); - expect(requestAnimationFrameSpy).toHaveBeenCalled(); - } finally { - await screen.unmount(); - } - }); - - it("starts long user messages collapsed by default", async () => { - const screen = await render( - , - ); - - try { - const toggle = page.getByRole("button", { name: "Show full message" }); - await expect.element(toggle).toBeVisible(); - await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); - - const messageBody = document.querySelector( - "[data-user-message-body='true']", - ) as HTMLDivElement | null; - expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true"); - expect(messageBody?.className).toContain("max-h-44"); - expect(messageBody?.className).toContain("overflow-hidden"); - expect(messageBody?.getAttribute("data-user-message-fade")).toBe("true"); - expect(messageBody?.style.maskImage).toContain("linear-gradient"); - } finally { - await screen.unmount(); - } - }); - - it("expands and re-collapses long user messages from the toggle", async () => { - const screen = await render( - , - ); - - try { - const expandButton = page.getByRole("button", { name: "Show full message" }); - await expect.element(expandButton).toBeVisible(); - - expect(document.body.textContent ?? "").toContain("deep hidden detail only after expand"); - - await expandButton.click(); - - const collapseButton = page.getByRole("button", { name: "Show less" }); - await expect.element(collapseButton).toBeVisible(); - await expect.element(collapseButton).toHaveAttribute("aria-expanded", "true"); - - let messageBody = document.querySelector("[data-user-message-body='true']"); - expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("false"); - expect(messageBody?.className).not.toContain("max-h-44"); - expect(messageBody?.getAttribute("data-user-message-fade")).toBe("false"); - expect((messageBody as HTMLDivElement | null)?.style.maskImage ?? "").toBe(""); - - await collapseButton.click(); - - await expect.element(page.getByRole("button", { name: "Show full message" })).toBeVisible(); - messageBody = document.querySelector("[data-user-message-body='true']"); - expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true"); - expect(messageBody?.className).toContain("max-h-44"); - expect(messageBody?.getAttribute("data-user-message-fade")).toBe("true"); - expect((messageBody as HTMLDivElement | null)?.style.maskImage).toContain("linear-gradient"); - } finally { - await screen.unmount(); - } - }); - - it("starts the newest long user prompt collapsed", async () => { - const screen = await render( - , - ); - - try { - await expect.element(page.getByRole("button", { name: "Show full message" })).toBeVisible(); - - const messageBody = document.querySelector("[data-user-message-body='true']"); - expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true"); - } finally { - await screen.unmount(); - } - }); - - it("renders user messages as markdown with chat-style line breaks", async () => { - const screen = await render( - , - ); - - try { - await expect.element(page.getByRole("heading", { level: 2, name: "Plan" })).toBeVisible(); - await expect - .element(page.getByRole("link", { name: "a link" })) - .toHaveAttribute("href", "https://example.com"); - - const messageBody = document.querySelector("[data-user-message-body='true']"); - expect(messageBody?.querySelector("strong")?.textContent).toBe("bold"); - // remark-breaks: the single newline between the inline runs is a
. - expect(messageBody?.querySelectorAll("p br").length).toBe(1); - } finally { - await screen.unmount(); - } - }); - - it("renders markdown file tags in user and assistant messages", async () => { - const fileLink = "[package.json](path/to/package.json)"; - const screen = await render( - , - ); - - try { - const userFileLink = document.querySelector( - '[data-message-role="user"] .chat-markdown-file-link', - ); - const assistantFileLink = document.querySelector( - '[data-message-role="assistant"] .chat-markdown-file-link', - ); - - expect(userFileLink?.textContent).toContain("package.json"); - expect(userFileLink?.getAttribute("href")).toBe("/repo/project/path/to/package.json"); - expect(assistantFileLink?.textContent).toContain("package.json"); - expect(assistantFileLink?.getAttribute("href")).toBe("/repo/project/path/to/package.json"); - } finally { - await screen.unmount(); - } - }); - - it("uses the file path without line suffix for markdown file tag icons", async () => { - const fileLink = "[package.json](path/to/package.json:25)"; - const screen = await render( - , - ); - - try { - const assistantFileLink = document.querySelector( - '[data-message-role="assistant"] .chat-markdown-file-link', - ); - const icon = assistantFileLink?.querySelector("svg[data-pierre-icon]"); - - expect(assistantFileLink?.textContent).toContain("package.json"); - expect(assistantFileLink?.textContent).toContain("L25"); - expect(assistantFileLink?.getAttribute("href")).toBe("/repo/project/path/to/package.json:25"); - expect(icon?.getAttribute("data-pierre-icon")).toBe("t3-file-icon-package-json"); - } finally { - await screen.unmount(); - } - }); - - it("folds settled-turn work behind a Worked-for row and expands it on click", async () => { - const screen = await render( - , - ); - - try { - const foldButton = page.getByRole("button", { name: "Worked for 30s" }); - await expect.element(foldButton).toBeVisible(); - await expect.element(foldButton).toHaveAttribute("aria-expanded", "false"); - - expect(document.body.textContent).toContain("All done."); - expect(document.body.textContent).not.toContain("Let me look around first."); - expect(document.body.textContent).not.toContain("Inspecting repository state"); - - await foldButton.click(); - - await expect.element(foldButton).toHaveAttribute("aria-expanded", "true"); - expect(document.body.textContent).toContain("Let me look around first."); - expect(document.body.textContent).toContain("Inspecting repository state"); - - await foldButton.click(); - - await expect.element(foldButton).toHaveAttribute("aria-expanded", "false"); - expect(document.body.textContent).not.toContain("Inspecting repository state"); - } finally { - await screen.unmount(); - } - }); -}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index bf8299e109d..0aefdfeeb3b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -14,7 +14,8 @@ describe("computeMessageDurationStart", () => { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:05Z", - completedAt: "2026-01-01T00:00:10Z", + updatedAt: "2026-01-01T00:00:10Z", + streaming: false, }, ]); expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); @@ -22,12 +23,19 @@ describe("computeMessageDurationStart", () => { it("uses the user message createdAt for the first assistant response", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, }, ]); @@ -39,20 +47,28 @@ describe("computeMessageDurationStart", () => { ); }); - it("uses the previous assistant completedAt for subsequent assistant responses", () => { + it("uses the previous completed assistant updatedAt for subsequent assistant responses", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, }, { id: "a2", role: "assistant", createdAt: "2026-01-01T00:00:55Z", - completedAt: "2026-01-01T00:00:55Z", + updatedAt: "2026-01-01T00:00:55Z", + streaming: false, }, ]); @@ -65,15 +81,28 @@ describe("computeMessageDurationStart", () => { ); }); - it("does not advance the boundary for a streaming message without completedAt", () => { + it("does not advance the boundary for a streaming message", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, - { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:40Z", + streaming: true, + }, { id: "a2", role: "assistant", createdAt: "2026-01-01T00:00:55Z", - completedAt: "2026-01-01T00:00:55Z", + updatedAt: "2026-01-01T00:00:55Z", + streaming: false, }, ]); @@ -88,19 +117,33 @@ describe("computeMessageDurationStart", () => { it("resets the boundary on a new user message", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, + }, + { + id: "u2", + role: "user", + createdAt: "2026-01-01T00:01:00Z", + updatedAt: "2026-01-01T00:01:00Z", + streaming: false, }, - { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" }, { id: "a2", role: "assistant", createdAt: "2026-01-01T00:01:20Z", - completedAt: "2026-01-01T00:01:20Z", + updatedAt: "2026-01-01T00:01:20Z", + streaming: false, }, ]); @@ -116,13 +159,26 @@ describe("computeMessageDurationStart", () => { it("handles system messages without affecting the boundary", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, - { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + { + id: "s1", + role: "system", + createdAt: "2026-01-01T00:00:01Z", + updatedAt: "2026-01-01T00:00:01Z", + streaming: false, + }, { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, }, ]); @@ -218,6 +274,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Write a poem", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, @@ -231,7 +288,7 @@ describe("deriveMessagesTimelineRows", () => { text: "I should ground this first.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", + updatedAt: "2026-01-01T00:00:11Z", streaming: false, }, }, @@ -245,7 +302,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Here is the poem.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", streaming: false, }, }, @@ -280,7 +337,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Earlier response.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", + updatedAt: "2026-01-01T00:00:11Z", streaming: false, }, }, @@ -294,7 +351,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Active response.", turnId: "turn-2" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", streaming: false, }, }, @@ -326,7 +383,9 @@ describe("deriveMessagesTimelineRows", () => { completedAt: "2026-01-01T00:00:30Z", assistantMessageId: "assistant-1" as never, checkpointTurnCount: 2, - files: [{ path: "src/index.ts", additions: 3, deletions: 1 }], + checkpointRef: "checkpoint-1" as never, + status: "ready" as const, + files: [{ path: "src/index.ts", kind: "modified", additions: 3, deletions: 1 }], }; const rows = deriveMessagesTimelineRows({ @@ -341,6 +400,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Do the thing", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, @@ -354,7 +414,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Done", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", streaming: false, }, }, @@ -392,6 +452,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Build it", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, @@ -405,7 +466,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Looking around first.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:05Z", - completedAt: "2026-01-01T00:00:06Z", + updatedAt: "2026-01-01T00:00:06Z", streaming: false, }, }, @@ -431,7 +492,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Done", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:22Z", + updatedAt: "2026-01-01T00:00:22Z", streaming: false, }, }, @@ -451,7 +512,7 @@ describe("deriveMessagesTimelineRows", () => { ); expect(foldRow?.turnId).toBe("turn-1"); expect(foldRow?.expanded).toBe(false); - // User message boundary (00:00:00) → terminal message completedAt (00:00:22). + // User message boundary (00:00:00) → terminal message updatedAt (00:00:22). expect(foldRow?.label).toBe("Worked for 22s"); expect(collapsedRows.map((row) => row.id)).toEqual([ "user-entry", @@ -484,7 +545,7 @@ describe("deriveMessagesTimelineRows", () => { // A steer ends the previous turn early: its only message completes the // instant it is created, and trailing work entries land after it. The // fold duration must span from the user message that started the turn to - // the last entry, not message createdAt → message completedAt (~0ms). + // the last entry, not message createdAt → message updatedAt (~0ms). const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -497,6 +558,7 @@ describe("deriveMessagesTimelineRows", () => { text: "do it once more", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, @@ -510,7 +572,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Kicking off call 1.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:09Z", - completedAt: "2026-01-01T00:00:09Z", + updatedAt: "2026-01-01T00:00:09Z", streaming: false, }, }, @@ -536,6 +598,7 @@ describe("deriveMessagesTimelineRows", () => { text: "actually do 15", turnId: null, createdAt: "2026-01-01T00:00:14Z", + updatedAt: "2026-01-01T00:00:14Z", streaming: false, }, }, @@ -549,6 +612,7 @@ describe("deriveMessagesTimelineRows", () => { text: "One down — adjusting.", turnId: "turn-2" as never, createdAt: "2026-01-01T00:00:17Z", + updatedAt: "2026-01-01T00:00:17Z", streaming: true, }, }, @@ -639,7 +703,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Done", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:22Z", + updatedAt: "2026-01-01T00:00:22Z", streaming: false, }, }, @@ -653,6 +717,7 @@ describe("deriveMessagesTimelineRows", () => { text: "yooo", turnId: null, createdAt: "2026-01-01T00:01:00Z", + updatedAt: "2026-01-01T00:01:00Z", streaming: false, }, }, @@ -692,7 +757,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Working on it.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:05Z", - completedAt: "2026-01-01T00:00:06Z", + updatedAt: "2026-01-01T00:00:06Z", streaming: false, }, }, @@ -718,7 +783,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Still working.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:12Z", - completedAt: "2026-01-01T00:00:13Z", + updatedAt: "2026-01-01T00:00:13Z", streaming: false, }, }, @@ -757,7 +822,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Checking first.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", + updatedAt: "2026-01-01T00:00:11Z", streaming: false, }, }, @@ -771,7 +836,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Done.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", streaming: false, }, }, @@ -804,7 +869,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Working on it.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", + updatedAt: "2026-01-01T00:00:11Z", streaming: false, }, }, @@ -839,6 +904,7 @@ describe("computeStableMessagesTimelineRows", () => { text: "First", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }; const secondUserMessage = { @@ -847,6 +913,7 @@ describe("computeStableMessagesTimelineRows", () => { text: "Second", turnId: null, createdAt: "2026-01-01T00:00:10Z", + updatedAt: "2026-01-01T00:00:10Z", streaming: false, }; @@ -942,6 +1009,7 @@ describe("computeStableMessagesTimelineRows", () => { text: "First", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }; const secondUserMessage = { @@ -950,6 +1018,7 @@ describe("computeStableMessagesTimelineRows", () => { text: "Second", turnId: null, createdAt: "2026-01-01T00:00:10Z", + updatedAt: "2026-01-01T00:00:10Z", streaming: false, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1f34708b59b..bd4bffe6b35 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -26,7 +26,8 @@ export interface TimelineDurationMessage { id: string; role: "user" | "assistant" | "system"; createdAt: string; - completedAt?: string | undefined; + updatedAt: string; + streaming: boolean; } export type TimelineLatestTurn = Pick< @@ -92,8 +93,8 @@ export function computeMessageDurationStart( lastBoundary = message.createdAt; } result.set(message.id, lastBoundary ?? message.createdAt); - if (message.role === "assistant" && message.completedAt) { - lastBoundary = message.completedAt; + if (message.role === "assistant" && !message.streaming) { + lastBoundary = message.updatedAt; } } @@ -263,9 +264,7 @@ function deriveTurnFolds(input: { // A turn cut short by a steer leaves trailing work entries behind its // terminal message — take whichever ended last. const lastEntryEnd = - lastEntry.kind === "message" - ? (lastEntry.message.completedAt ?? lastEntry.createdAt) - : lastEntry.createdAt; + lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; const elapsedMs = input.latestTurn?.turnId === turnId && input.latestTurn.startedAt && @@ -273,7 +272,7 @@ function deriveTurnFolds(input: { ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt) : computeElapsedMs( group.startBoundary ?? firstEntry.createdAt, - maxIsoTimestamp(group.terminalEntry?.message.completedAt ?? null, lastEntryEnd) ?? + maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? lastEntryEnd, ); const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f7da222f441..ce690b68770 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -13,9 +13,21 @@ vi.mock("@legendapp/list/react", async () => { renderItem: (args: { item: { id: string } }) => ReactNode; ListHeaderComponent?: ReactNode; ListFooterComponent?: ReactNode; + anchoredEndSpace?: { + anchorIndex: number; + anchorMaxSize?: number; + anchorOffset?: number; + }; + contentInsetEndAdjustment?: number; ref?: Ref; }) => ( -
+
{props.ListHeaderComponent} {props.data.map((item) => (
{props.renderItem({ item })}
@@ -109,6 +121,8 @@ function buildProps() { resolvedTheme: "light" as const, timestampFormat: "locale" as const, workspaceRoot: undefined, + anchorMessageId: null, + contentInsetEndAdjustment: 0, onIsAtEndChange: () => {}, }; } @@ -128,13 +142,51 @@ function buildUserTimelineEntry(text: string) { id: MessageId.make("message-1"), role: "user" as const, text, + turnId: null, createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, streaming: false, }, }; } describe("MessagesTimeline", () => { + it("anchors a sent attachment message using its measured height", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const firstEntry = buildUserTimelineEntry("First prompt."); + const secondEntry = { + ...buildUserTimelineEntry("Newest prompt."), + id: "entry-2", + message: { + ...buildUserTimelineEntry("Newest prompt.").message, + id: MessageId.make("message-2"), + attachments: [ + { + type: "image" as const, + id: "attachment-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl: "data:image/png;base64,iVBORw0KGgo=", + }, + ], + }, + }; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-anchor-index="1"'); + expect(markup).toContain('data-anchor-offset="16"'); + expect(markup).not.toContain("data-anchor-max-size="); + expect(markup).toContain('data-content-inset-end="144"'); + }, 20_000); + it("renders collapse controls for long user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -191,6 +243,33 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Show full message"); }, 20_000); + it("renders chips for standalone element-pick context messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ", + "- (Button.tsx:12):", + " url: https://example.com/dashboard", + " selector: button.submit", + " source: /repo/src/Button.tsx:12:5", + " html:", + ' ', + "", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("SubmitButton"); + expect(markup).not.toContain("<element_context"); + expect(markup).not.toContain(" { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -227,7 +306,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain("Context compacted"); - expect(markup).toContain("work log"); + expect(markup).toContain("Work Log"); }); it("formats changed file paths from the workspace root", async () => { @@ -280,7 +359,9 @@ describe("MessagesTimeline", () => { "```", "", ].join("\n"), + turnId: null, createdAt: "2026-03-17T19:12:28.000Z", + updatedAt: "2026-03-17T19:12:28.000Z", streaming: false, }, }, @@ -318,7 +399,9 @@ describe("MessagesTimeline", () => { "```", "", ].join("\n"), + turnId: null, createdAt: "2026-03-17T19:12:28.000Z", + updatedAt: "2026-03-17T19:12:28.000Z", streaming: false, }, }, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3ed72bea84c..ede3889ef9a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -5,7 +5,8 @@ import { type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; -import { parseScopedThreadKey } from "@t3tools/client-runtime"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { createContext, Fragment, @@ -166,6 +167,8 @@ interface MessagesTimelineProps { timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray>; + anchorMessageId: MessageId | null; + contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => void; } @@ -193,6 +196,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, + anchorMessageId, + contentInsetEndAdjustment, onIsAtEndChange, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); @@ -285,6 +290,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); + const anchoredEndSpace = useMemo( + () => + resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => + row.kind === "message" ? row.message.id : null, + ), + [anchorMessageId, rows], + ); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -293,24 +305,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [listRef, onIsAtEndChange]); - const previousRowCountRef = useRef(rows.length); - useEffect(() => { - const previousRowCount = previousRowCountRef.current; - previousRowCountRef.current = rows.length; - - if (previousRowCount > 0 || rows.length === 0) { - return; - } - - onIsAtEndChange(true); - const frameId = window.requestAnimationFrame(() => { - void listRef.current?.scrollToEnd?.({ animated: false }); - }); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [listRef, onIsAtEndChange, rows.length]); - const sharedState = useMemo( () => ({ timestampFormat, @@ -377,14 +371,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ref={listRef} data={rows} keyExtractor={keyExtractor} + getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={!foldToggleSettling} maintainScrollAtEndThreshold={0.1} maintainVisibleContentPosition onScroll={handleScroll} - className="scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" + className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 sm:px-5" ListHeaderComponent={TIMELINE_LIST_HEADER} ListFooterComponent={TIMELINE_LIST_FOOTER} /> @@ -397,6 +394,10 @@ function keyExtractor(item: MessagesTimelineRow) { return item.id; } +function getItemType(item: MessagesTimelineRow) { + return item.kind === "message" ? `message:${item.message.role}` : item.kind; +} + // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- @@ -453,6 +454,10 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; @@ -500,9 +505,9 @@ function UserTimelineRow({ row }: { row: Extract ))} - {elementContextState.contexts.length > 0 ? ( + {elementContexts.length > 0 ? (
- {elementContextState.contexts.map((context) => ( + {elementContexts.map((context) => ( } > - {formatShortTimestamp( - row.message.completedAt ?? row.message.createdAt, - ctx.timestampFormat, - )} + {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} - {formatChatTimestampTooltip( - row.message.completedAt ?? row.message.createdAt, - ctx.timestampFormat, - )} + {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} )} @@ -747,7 +746,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ ? nonEmptyEntries.length === 1 ? "1 tool call" : `${nonEmptyEntries.length} tool calls` - : "work log"; + : "Work Log"; useLayoutEffect(() => { const anchorBottomBeforeToggle = anchorBottomBeforeToggleRef.current; diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 740b54d9c5a..3f8915e5d8b 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -8,6 +8,7 @@ import { PROVIDER_ICON_BY_PROVIDER, } from "./providerIconUtils"; import { ComboboxItem } from "../ui/combobox"; +import { Button } from "../ui/button"; import { Kbd } from "../ui/kbd"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; @@ -92,9 +93,11 @@ export const ModelListRow = memo(function ModelListRow(props: { { @@ -105,7 +108,6 @@ export const ModelListRow = memo(function ModelListRow(props: { event.stopPropagation(); }} disabled={Boolean(props.disabledReason)} - type="button" aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} > - + } /> diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 3a9b421de01..e0d6b9afc5e 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -19,10 +19,14 @@ import { resolveShortcutCommand, shortcutLabelForCommand, } from "../../keybindings"; -import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { TooltipProvider } from "../ui/tooltip"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { + isProviderInstancePickerReady, + isProviderInstancePickerVisible, + type ProviderInstanceEntry, +} from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; type ModelPickerItem = { @@ -98,7 +102,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const searchInputRef = useRef(null); const modelListRef = useRef(null); const highlightedModelKeyRef = useRef(null); - const favorites = useSettings((s) => s.favorites ?? []); + const favorites = useClientSettings((s) => s.favorites ?? []); const [selectedInstanceId, setSelectedInstanceId] = useState( () => { if (props.lockedProvider !== null) { @@ -113,7 +117,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { () => providedKeybindings ?? [], [providedKeybindings], ); - const { updateSettings } = useUpdateSettings(); + const updateSettings = useUpdateClientSettings(); const focusSearchInput = useCallback(() => { searchInputRef.current?.focus({ preventScroll: true }); @@ -174,7 +178,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const readyInstanceSet = useMemo(() => { const ready = new Set(); for (const entry of instanceEntries) { - if (entry.status === "ready") { + if (isProviderInstancePickerReady(entry)) { ready.add(entry.instanceId); } } @@ -231,12 +235,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return disabled; }, [instanceEntries, isLocked, matchesLockedProvider]); const sidebarInstanceEntries = useMemo(() => { + const enabledEntries = instanceEntries.filter(isProviderInstancePickerVisible); if (!isLocked) { - return instanceEntries; + return enabledEntries; } const available: ProviderInstanceEntry[] = []; const disabled: ProviderInstanceEntry[] = []; - for (const entry of instanceEntries) { + for (const entry of enabledEntries) { if (matchesLockedProvider(entry)) { available.push(entry); } else { @@ -526,7 +531,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onSelectInstance={handleSelectInstance} instanceEntries={sidebarInstanceEntries} showFavorites - showComingSoon {...(lockedDisabledInstanceIds ? { disabledInstanceIds: lockedDisabledInstanceIds, @@ -550,7 +554,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onItemHighlighted={(modelKey, eventDetails) => { highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { - modelListRef.current?.scrollIndexIntoView?.({ + void modelListRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false, }); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index ea1693492f0..e5555cb0115 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,11 +1,10 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; -import { Gemini, GithubCopilotIcon } from "../Icons"; +import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; /** * Build the hover tooltip for an instance button. Mirrors the old @@ -14,17 +13,14 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; */ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { const label = entry.displayName; - if (entry.status === "ready") { + if (!entry.enabled || entry.status === "disabled") { + return `${label} — Disabled in settings.`; + } + if (entry.status === "ready" && entry.isAvailable) { return label; } const kind = - entry.status === "error" - ? "Unavailable" - : entry.status === "warning" - ? "Limited" - : entry.status === "disabled" - ? "Disabled in settings" - : "Not ready"; + entry.status === "error" ? "Unavailable" : entry.status === "warning" ? "Limited" : "Not ready"; const msg = entry.snapshot.message?.trim(); return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; } @@ -34,7 +30,6 @@ const SELECTED_INDICATOR_CLASS = const BADGE_BASE_CLASS = "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; -const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; /** Opens toward the rail so the list stays readable (not over the model names). */ const PICKER_TOOLTIP_SIDE = "left" as const; @@ -53,8 +48,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { instanceEntries: ReadonlyArray; /** Render the favorites rail entry. Hidden for locked-provider instance switching. */ showFavorites?: boolean; - /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ - showComingSoon?: boolean; /** Instance ids shown in the rail but unavailable for the current picker context. */ disabledInstanceIds?: ReadonlySet; getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; @@ -69,7 +62,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { props.onSelectInstance(instanceId); }; const showFavorites = props.showFavorites ?? true; - const showComingSoon = props.showComingSoon ?? true; const [hoveredInstanceId, setHoveredInstanceId] = useState(null); const sidebarContentRef = useRef(null); const [selectedIndicatorTop, setSelectedIndicatorTop] = useState(null); @@ -159,7 +151,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { {/* Instance buttons (one per configured instance — built-in + custom) */} {props.instanceEntries.map((entry) => { - const isUnavailable = !entry.isAvailable || entry.status !== "ready"; + const isUnavailable = !isProviderInstancePickerReady(entry); const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; const isDisabled = isUnavailable || isContextDisabled; const isSelected = props.selectedInstanceId === entry.instanceId; @@ -251,76 +243,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
); })} - - {showComingSoon ? ( - <> - {/* Gemini button (coming soon) */} - - - - - } - /> - - Gemini — Coming soon - - - {/* Github Copilot button (coming soon) */} - - - - - } - /> - - Github Copilot — Coming soon - - - - ) : null}
diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 1bb9c0a42e5..9def7a4646c 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,4 +1,4 @@ -import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; @@ -32,7 +32,8 @@ import { WebStormIcon, } from "../JetBrainsIcons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; -import { readLocalApi } from "~/localApi"; +import { shellEnvironment } from "~/state/shell"; +import { useAtomCommand } from "~/state/use-atom-command"; const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ @@ -151,18 +152,21 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray; openInCwd: string | null; compact?: boolean; enableShortcut?: boolean; }) { + const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( () => resolveOptions(navigator.platform, availableEditors), @@ -172,14 +176,20 @@ export const OpenInPicker = memo(function OpenInPicker({ const openInEditor = useCallback( (editorId: EditorId | null) => { - const api = readLocalApi(); - if (!api || !openInCwd) return; + if (!openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); + const result = openInEditorMutation({ + environmentId, + input: { + cwd: openInCwd, + editor, + }, + }); setPreferredEditor(editor); + return result; }, - [preferredEditor, openInCwd, setPreferredEditor], + [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -190,17 +200,29 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { if (!enableShortcut) return; const handler = (e: globalThis.KeyboardEvent) => { - const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; + if (!openInCwd) return; if (!preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, preferredEditor); + void openInEditorMutation({ + environmentId, + input: { + cwd: openInCwd, + editor: preferredEditor, + }, + }); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [enableShortcut, preferredEditor, keybindings, openInCwd]); + }, [ + enableShortcut, + environmentId, + keybindings, + openInCwd, + openInEditorMutation, + preferredEditor, + ]); return ( diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 9b5c37099a1..9746857a8ca 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -1,4 +1,8 @@ import { memo, useState, useId } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, @@ -25,8 +29,9 @@ import { DialogTitle, } from "../ui/dialog"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { readEnvironmentApi } from "~/environmentApi"; +import { projectEnvironment } from "~/state/projects"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useAtomCommand } from "~/state/use-atom-command"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, @@ -45,7 +50,11 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [savePath, setSavePath] = useState(""); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { + reportFailure: false, + }); const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "plan", onError: (error) => { toastManager.add( stackedThreadToast({ @@ -91,9 +100,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }; const handleSaveToWorkspace = () => { - const api = readEnvironmentApi(environmentId); const relativePath = savePath.trim(); - if (!api || !workspaceRoot) { + if (!workspaceRoot) { return; } if (!relativePath) { @@ -105,21 +113,27 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ } setIsSavingToWorkspace(true); - void api.projects - .writeFile({ - cwd: workspaceRoot, - relativePath, - contents: saveContents, - }) - .then((result) => { + void (async () => { + const result = await writeProjectFile({ + environmentId, + input: { + cwd: workspaceRoot, + relativePath, + contents: saveContents, + }, + }); + setIsSavingToWorkspace(false); + if (result._tag === "Success") { setIsSaveDialogOpen(false); toastManager.add({ type: "success", title: "Plan saved to workspace", - description: result.relativePath, + description: result.value.relativePath, }); - }) - .catch((error) => { + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -127,15 +141,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ description: error instanceof Error ? error.message : "An error occurred while saving.", }), ); - }) - .then( - () => { - setIsSavingToWorkspace(false); - }, - () => { - setIsSavingToWorkspace(false); - }, - ); + } + })(); }; return ( diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx deleted file mode 100644 index 1952d77d4f4..00000000000 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ /dev/null @@ -1,1316 +0,0 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { EnvironmentId } from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import { page, userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { ProviderModelPicker } from "./ProviderModelPicker"; -import { getCustomModelOptionsByInstance } from "../../modelSelection"; -import { - deriveProviderInstanceEntries, - sortProviderInstanceEntries, -} from "../../providerInstances"; -import type { ModelEsque } from "./providerIconUtils"; -import { - DEFAULT_CLIENT_SETTINGS, - DEFAULT_UNIFIED_SETTINGS, - type UnifiedSettings, -} from "@t3tools/contracts/settings"; -import { __resetLocalApiForTests } from "../../localApi"; - -// Mock the environments/runtime module to provide a mock primary environment connection -vi.mock("../../environments/runtime", () => { - const primaryConnection = { - kind: "primary" as const, - knownEnvironment: { - id: "environment-local", - label: "Local environment", - source: "manual" as const, - environmentId: EnvironmentId.make("environment-local"), - target: { - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - }, - }, - environmentId: EnvironmentId.make("environment-local"), - client: { - server: { - getConfig: vi.fn(), - updateSettings: vi.fn(), - }, - }, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }; - - return { - getEnvironmentHttpBaseUrl: () => "http://localhost:3000", - getSavedEnvironmentRecord: () => null, - getSavedEnvironmentRuntimeState: () => null, - hasSavedEnvironmentRegistryHydrated: () => true, - listSavedEnvironmentRecords: () => [], - resetSavedEnvironmentRegistryStoreForTests: vi.fn(), - resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), - resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => - new URL(path, "http://localhost:3000").toString(), - waitForSavedEnvironmentRegistryHydration: async () => undefined, - addSavedEnvironment: vi.fn(), - disconnectSavedEnvironment: vi.fn(), - ensureEnvironmentConnectionBootstrapped: async () => undefined, - getPrimaryEnvironmentConnection: () => primaryConnection, - readEnvironmentConnection: () => primaryConnection, - reconnectSavedEnvironment: vi.fn(), - removeSavedEnvironment: vi.fn(), - requireEnvironmentConnection: () => primaryConnection, - resetEnvironmentServiceForTests: vi.fn(), - startEnvironmentConnectionService: vi.fn(), - subscribeEnvironmentConnections: () => () => {}, - useSavedEnvironmentRegistryStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - useSavedEnvironmentRuntimeStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - }; -}); - -function selectDescriptor( - id: string, - label: string, - options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, -) { - return { - id, - label, - type: "select" as const, - options: [...options], - ...(options.find((option) => option.isDefault)?.id - ? { currentValue: options.find((option) => option.isDefault)?.id } - : {}), - }; -} - -function booleanDescriptor(id: string, label: string) { - return { - id, - label, - type: "boolean" as const, - }; -} - -const TEST_PROVIDERS: ReadonlyArray = [ - { - driver: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - displayName: "Codex", - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: new Date().toISOString(), - slashCommands: [], - skills: [], - models: [ - { - slug: "gpt-5-codex", - name: "GPT-5 Codex", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - ], - }), - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - ], - }), - }, - ], - }, - { - driver: ProviderDriverKind.make("claudeAgent"), - instanceId: ProviderInstanceId.make("claudeAgent"), - displayName: "Claude", - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: new Date().toISOString(), - slashCommands: [], - skills: [], - models: [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("effort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - { id: "max", label: "max" }, - ]), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("effort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - { id: "max", label: "max" }, - ]), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("effort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - }, -]; - -const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); -const CLAUDE_INSTANCE_ID = ProviderInstanceId.make("claudeAgent"); -const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); - -function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { - return { - driver: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - displayName: "Codex", - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: new Date().toISOString(), - models, - slashCommands: [], - skills: [], - }; -} - -function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { - return { - driver: ProviderDriverKind.make("opencode"), - instanceId: ProviderInstanceId.make("opencode"), - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: new Date().toISOString(), - models, - slashCommands: [], - skills: [], - }; -} - -async function mountPicker(props: { - activeInstanceId?: ProviderInstanceId; - model: string; - lockedProvider: ProviderDriverKind | null; - lockedContinuationGroupKey?: string | null; - providers?: ReadonlyArray; - settings?: UnifiedSettings; - triggerVariant?: "ghost" | "outline"; - getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; -}) { - const host = document.createElement("div"); - document.body.append(host); - const onInstanceModelChange = vi.fn(); - const providers = props.providers ?? TEST_PROVIDERS; - const instanceEntries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); - const activeInstanceId = props.activeInstanceId ?? CODEX_INSTANCE_ID; - const modelOptionsByInstance = getCustomModelOptionsByInstance( - props.settings ?? DEFAULT_UNIFIED_SETTINGS, - providers, - activeInstanceId, - props.model, - ); - const screen = await render( - , - { container: host }, - ); - - return { - onInstanceModelChange, - // Back-compat alias used by callers that still assert on the old callback - // name. Delegates to the instance-aware mock so existing expectations work. - get onProviderModelChange() { - return onInstanceModelChange; - }, - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -function getModelPickerListElement() { - const modelPickerList = document.querySelector(".model-picker-list"); - expect(modelPickerList).not.toBeNull(); - return modelPickerList!; -} - -function getModelPickerListText() { - return getModelPickerListElement().textContent ?? ""; -} - -function getVisibleModelNames() { - return Array.from(getModelPickerListElement().querySelectorAll("div.font-medium")) - .map((element) => element.textContent?.replace(/New$/u, "").trim() ?? "") - .filter((text) => text.length > 0); -} - -function getSidebarProviderOrder() { - return Array.from(document.querySelectorAll("[data-model-picker-provider]")).map( - (element) => element.dataset.modelPickerProvider ?? "", - ); -} - -describe("ProviderModelPicker", () => { - beforeEach(async () => { - // Reset test environment before each test - await __resetLocalApiForTests(); - }); - - afterEach(async () => { - document.body.innerHTML = ""; - await __resetLocalApiForTests(); - }); - - it("shows provider sidebar in unlocked mode", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).not.toContain("Codex"); - expect(text).toContain("Claude"); - expect(text).toContain("Claude Opus 4.6"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("shows favorites first in the provider sidebar", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ - "favorites", - "codex", - "claudeAgent", - ]); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("filters models by selected provider in sidebar", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - // Start with Claude models visible - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).not.toContain("GPT-5 Codex"); - expect(text).toContain("Claude Opus 4.6"); - }); - - // Click on Codex provider in sidebar - await vi.waitFor(() => { - expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); - }); - await page.getByRole("button", { name: "Codex", exact: true }).click(); - - // Now should only show Codex models - await vi.waitFor(() => { - const listText = getModelPickerListText(); - expect(listText).toContain("GPT-5 Codex"); - expect(listText).not.toContain("Claude Opus 4.6"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("uses client model visibility and ordering preferences", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - settings: { - ...DEFAULT_UNIFIED_SETTINGS, - providerModelPreferences: { - [CLAUDE_INSTANCE_ID]: { - hiddenModels: ["claude-opus-4-6"], - modelOrder: ["claude-haiku-4-5", "claude-sonnet-4-6"], - }, - }, - }, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(getVisibleModelNames()).toEqual(["Claude Haiku 4.5", "Claude Sonnet 4.6"]); - expect(getModelPickerListText()).not.toContain("Claude Opus 4.6"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("focuses the search input after selecting a sidebar provider", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); - }); - await page.getByRole("button", { name: "Codex", exact: true }).click(); - - await vi.waitFor(() => { - const searchInput = document.querySelector( - 'input[placeholder="Search models..."]', - ); - expect(searchInput).not.toBeNull(); - expect(document.activeElement).toBe(searchInput); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the full provider rail in locked mode and only lists compatible models", async () => { - localStorage.setItem( - "t3code:client-settings:v1", - JSON.stringify({ - ...DEFAULT_CLIENT_SETTINGS, - favorites: [ - { provider: "codex", model: "gpt-5-codex" }, - { provider: "claudeAgent", model: "claude-sonnet-4-6" }, - ], - }), - ); - - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: ProviderDriverKind.make("claudeAgent"), - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude"); - // Locked-compatible instances render first, then disabled ones. - expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ - "favorites", - "claudeAgent", - "codex", - ]); - expect( - document.querySelector('[data-model-picker-provider="codex"]') - ?.disabled, - ).toBe(true); - expect( - document.querySelector('[data-model-picker-provider="claudeAgent"]') - ?.disabled, - ).toBe(false); - expect(getVisibleModelNames()).toEqual([ - "Claude Sonnet 4.6", - "Claude Opus 4.6", - "Claude Haiku 4.5", - ]); - }); - } finally { - localStorage.removeItem("t3code:client-settings:v1"); - await mounted.cleanup(); - } - }); - - it("keeps an instance sidebar in locked mode when that provider has multiple instances", async () => { - const defaultCodexModels: ServerProvider["models"] = [ - { - slug: "gpt-work", - name: "GPT Work", - isCustom: false, - capabilities: createModelCapabilities({ optionDescriptors: [] }), - }, - ]; - const personalCodexModels: ServerProvider["models"] = [ - { - slug: "gpt-personal", - name: "GPT Personal", - isCustom: false, - capabilities: createModelCapabilities({ optionDescriptors: [] }), - }, - ]; - const isolatedCodexModels: ServerProvider["models"] = [ - { - slug: "gpt-isolated", - name: "GPT Isolated", - isCustom: false, - capabilities: createModelCapabilities({ optionDescriptors: [] }), - }, - ]; - const providers: ReadonlyArray = [ - { - ...buildCodexProvider(defaultCodexModels), - instanceId: "codex" as ProviderInstanceId, - displayName: "Codex Work", - accentColor: "#2563eb", - continuation: { groupKey: "codex:home:/Users/julius/.codex" }, - }, - { - ...buildCodexProvider(personalCodexModels), - instanceId: "codex_personal" as ProviderInstanceId, - displayName: "Codex Personal", - accentColor: "#dc2626", - continuation: { groupKey: "codex:home:/Users/julius/.codex" }, - }, - { - ...buildCodexProvider(isolatedCodexModels), - instanceId: "codex_isolated" as ProviderInstanceId, - displayName: "Codex Isolated", - accentColor: "#16a34a", - continuation: { groupKey: "codex:home:/Users/julius/.codex_isolated" }, - }, - TEST_PROVIDERS[1]!, - ]; - const mounted = await mountPicker({ - activeInstanceId: "codex" as ProviderInstanceId, - model: "gpt-work", - lockedProvider: ProviderDriverKind.make("codex"), - lockedContinuationGroupKey: "codex:home:/Users/julius/.codex", - providers, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(getSidebarProviderOrder().slice(0, 5)).toEqual([ - "favorites", - "codex", - "codex_personal", - "codex_isolated", - "claudeAgent", - ]); - expect( - document.querySelector('[data-model-picker-provider="codex_isolated"]') - ?.disabled, - ).toBe(true); - expect( - document.querySelector('[data-model-picker-provider="claudeAgent"]') - ?.disabled, - ).toBe(true); - expect(getModelPickerListText()).not.toContain("Codex Isolated"); - expect( - document.querySelector('[data-model-picker-provider="codex_personal"]') - ?.dataset.providerAccentColor, - ).toBe("#dc2626"); - expect(getModelPickerListText()).toContain("Codex Work"); - expect(getVisibleModelNames()).toEqual(["GPT Work"]); - }); - - await page.getByRole("button", { name: "Codex Personal" }).click(); - - await vi.waitFor(() => { - expect(getModelPickerListText()).toContain("Codex Personal"); - expect(getVisibleModelNames()).toEqual(["GPT Personal"]); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => { - const host = document.createElement("div"); - document.body.append(host); - const onInstanceModelChange = vi.fn(); - const modelOptionsByInstance = new Map>([ - [ - "claudeAgent" as ProviderInstanceId, - [ - { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - ], - ], - ["codex" as ProviderInstanceId, [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }]], - ["cursor" as ProviderInstanceId, []], - ["opencode" as ProviderInstanceId, []], - ]); - const instanceEntries = sortProviderInstanceEntries( - deriveProviderInstanceEntries(TEST_PROVIDERS), - ); - const screen = await render( - , - { container: host }, - ); - - try { - const trigger = document.querySelector( - '[data-chat-provider-model-picker="true"]', - ); - expect(trigger).not.toBeNull(); - const label = trigger?.textContent ?? ""; - expect(label).not.toContain("gpt-5-codex"); - expect(label).toContain("Claude Opus 4.6"); - } finally { - await screen.unmount(); - host.remove(); - } - }); - - it("shows the plain model name in the trigger and provider details on locked opencode rows", async () => { - const providers: ReadonlyArray = [ - buildOpenCodeProvider([ - { - slug: "github-copilot/claude-opus-4.5", - name: "Claude Opus 4.5", - subProvider: "GitHub Copilot", - shortName: "Opus 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - ], - }), - }, - ]), - ]; - const mounted = await mountPicker({ - activeInstanceId: OPENCODE_INSTANCE_ID, - model: "github-copilot/claude-opus-4.5", - lockedProvider: ProviderDriverKind.make("opencode"), - providers, - }); - - try { - await vi.waitFor(() => { - const trigger = document.querySelector( - '[data-chat-provider-model-picker="true"]', - ); - expect(trigger?.textContent).toContain("Opus 4.5"); - expect(trigger?.textContent).not.toContain("GitHub Copilot"); - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(getVisibleModelNames()).toEqual(["Claude Opus 4.5"]); - expect(getModelPickerListText()).toContain("OpenCode · GitHub Copilot"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("searches models by name in flat list", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude Opus 4.6"); - expect(text).not.toContain("GPT-5 Codex"); - }); - - // Find and type in search box - const searchInput = page.getByPlaceholder("Search models..."); - await searchInput.fill("claude"); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude Opus 4.6"); - expect(text).not.toContain("GPT-5 Codex"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("supports arrow-key navigation in the model picker", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: ProviderDriverKind.make("claudeAgent"), - }); - - try { - await page.getByRole("button").click(); - - const searchInput = page.getByPlaceholder("Search models..."); - await userEvent.click(searchInput); - await userEvent.keyboard("{ArrowDown}"); - await vi.waitFor(() => { - const highlightedItem = document.querySelector( - '[data-slot="combobox-item"][data-highlighted]', - ); - expect(highlightedItem).not.toBeNull(); - expect(highlightedItem?.textContent).toContain("Claude Opus 4.6"); - }); - await userEvent.keyboard("{ArrowDown}"); - await vi.waitFor(() => { - const highlightedItem = document.querySelector( - '[data-slot="combobox-item"][data-highlighted]', - ); - expect(highlightedItem).not.toBeNull(); - expect(highlightedItem?.textContent).toContain("Claude Sonnet 4.6"); - }); - await userEvent.keyboard("{Enter}"); - - expect(mounted.onProviderModelChange).toHaveBeenCalledWith( - "claudeAgent", - "claude-sonnet-4-6", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("hides the provider sidebar while searching", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(getSidebarProviderOrder().length).toBeGreaterThan(0); - }); - - await page.getByPlaceholder("Search models...").fill("cla"); - - await vi.waitFor(() => { - expect(getSidebarProviderOrder()).toEqual([]); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("closes the picker when escape is pressed in search", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - const searchInput = page.getByPlaceholder("Search models..."); - await searchInput.click(); - const searchInputElement = document.querySelector( - 'input[placeholder="Search models..."]', - ); - expect(searchInputElement).not.toBeNull(); - searchInputElement!.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); - - await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).toBeNull(); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("searches models by provider name", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude Opus 4.6"); - expect(text).not.toContain("GPT-5 Codex"); - }); - - // Search by provider name - const searchInput = page.getByPlaceholder("Search models..."); - await searchInput.fill("codex"); - - await vi.waitFor(() => { - const listText = getModelPickerListText(); - expect(listText).toContain("GPT-5 Codex"); - expect(listText).not.toContain("Claude Opus 4.6"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("matches fuzzy multi-token queries across provider and model text", async () => { - const providers: ReadonlyArray = [ - buildCodexProvider([ - { - slug: "gpt-5-codex", - name: "GPT-5 Codex", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - ], - }), - }, - ]), - buildOpenCodeProvider([ - { - slug: "github-copilot/claude-opus-4.7", - name: "Claude Opus 4.7", - subProvider: "GitHub Copilot", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - ], - }), - }, - ]), - ]; - const mounted = await mountPicker({ - activeInstanceId: OPENCODE_INSTANCE_ID, - model: "github-copilot/claude-opus-4.7", - lockedProvider: null, - providers, - }); - - try { - await page.getByRole("button").click(); - await page.getByPlaceholder("Search models...").fill("coplt op"); - - await vi.waitFor(() => { - const listText = getModelPickerListText(); - expect(listText).toContain("Claude Opus 4.7"); - expect(listText).not.toContain("GPT-5 Codex"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("renders each search result with its own provider branding", async () => { - const providers: ReadonlyArray = [ - buildOpenCodeProvider([ - { - slug: "github-copilot/claude-opus-4.7", - name: "Claude Opus 4.7", - subProvider: "GitHub Copilot", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - ], - }), - }, - ]), - { - ...TEST_PROVIDERS[1]!, - models: [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("effort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - { id: "max", label: "max" }, - ]), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - }, - ]; - const mounted = await mountPicker({ - activeInstanceId: OPENCODE_INSTANCE_ID, - model: "github-copilot/claude-opus-4.7", - lockedProvider: null, - providers, - }); - - try { - await page.getByRole("button").click(); - await page.getByPlaceholder("Search models...").fill("opus"); - - await vi.waitFor(() => { - const listText = getModelPickerListText(); - expect(listText).toContain("OpenCode · GitHub Copilot"); - expect(listText).toContain("Claude"); - expect(listText).not.toContain("OpenCodeClaude Opus 4.6"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("toggles favorite stars when clicked", async () => { - localStorage.removeItem("t3code:client-settings:v1"); - - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude Opus 4.6"); - }); - - const getFavoriteButton = () => { - const modelRow = Array.from(document.querySelectorAll('[role="option"]')).find( - (row) => row.textContent?.includes("Claude Opus 4.6"), - ); - const starButton = modelRow?.querySelector( - 'button[aria-label*="favorites"]', - ); - expect(starButton).not.toBeNull(); - return starButton!; - }; - - const favoriteButton = getFavoriteButton(); - const initialAriaLabel = favoriteButton.getAttribute("aria-label"); - expect( - initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites", - ).toBe(true); - - await userEvent.click(favoriteButton); - - const expectedAriaLabel = - initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites"; - - await vi.waitFor(() => { - expect(getFavoriteButton().getAttribute("aria-label")).toBe(expectedAriaLabel); - }); - } finally { - await mounted.cleanup(); - localStorage.removeItem("t3code:client-settings:v1"); - } - }); - - it("does not duplicate favorited models across favorites and all models sections", async () => { - localStorage.removeItem("t3code:client-settings:v1"); - - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude Opus 4.6"); - }); - - const favoriteButton = page.getByRole("button", { - name: "Add to favorites", - }); - await favoriteButton.first().click(); - - await vi.waitFor(async () => { - const favoritedModelRows = Array.from( - getModelPickerListElement().querySelectorAll("div.font-medium"), - ).filter((element) => element.textContent?.trim() === "Claude Opus 4.6"); - expect(favoritedModelRows.length).toBe(1); - }); - } finally { - await mounted.cleanup(); - localStorage.removeItem("t3code:client-settings:v1"); - } - }); - - it("shows favorited models first within the selected provider list", async () => { - localStorage.setItem( - "t3code:client-settings:v1", - JSON.stringify({ - ...DEFAULT_CLIENT_SETTINGS, - favorites: [{ provider: "codex", model: "gpt-5.3-codex" }], - }), - ); - - const mounted = await mountPicker({ - model: "gpt-5-codex", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("button", { name: "Codex", exact: true }).click(); - - await vi.waitFor(() => { - expect(getVisibleModelNames().slice(0, 2)).toEqual(["GPT-5.3 Codex", "GPT-5 Codex"]); - }); - } finally { - await mounted.cleanup(); - localStorage.removeItem("t3code:client-settings:v1"); - } - }); - - it("filters favorites to compatible models in locked mode", async () => { - localStorage.setItem( - "t3code:client-settings:v1", - JSON.stringify({ - ...DEFAULT_CLIENT_SETTINGS, - favorites: [ - { provider: "codex", model: "gpt-5.3-codex" }, - { provider: "claudeAgent", model: "claude-sonnet-4-6" }, - ], - }), - ); - - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: ProviderDriverKind.make("claudeAgent"), - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("button", { name: "Favorites", exact: true }).click(); - - await vi.waitFor(() => { - expect(getVisibleModelNames()).toEqual(["Claude Sonnet 4.6"]); - expect(getModelPickerListText()).not.toContain("GPT-5.3 Codex"); - }); - } finally { - await mounted.cleanup(); - localStorage.removeItem("t3code:client-settings:v1"); - } - }); - - it("dispatches callback with correct provider and model when selected", async () => { - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: ProviderDriverKind.make("claudeAgent"), - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude Sonnet 4.6"); - }); - - // Click on a model - const modelRow = page.getByText("Claude Sonnet 4.6").first(); - await modelRow.click(); - - // Verify callback was called with correct values - expect(mounted.onProviderModelChange).toHaveBeenCalledWith( - "claudeAgent", - "claude-sonnet-4-6", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("does not select models blocked by the provider", async () => { - const disabledReason = - "This provider does not allow switching models after a conversation has started. Start a new thread to use this model."; - const mounted = await mountPicker({ - activeInstanceId: CLAUDE_INSTANCE_ID, - model: "claude-opus-4-6", - lockedProvider: ProviderDriverKind.make("claudeAgent"), - getModelDisabledReason: (instanceId, model) => - instanceId === CLAUDE_INSTANCE_ID && model !== "claude-opus-4-6" ? disabledReason : null, - }); - - try { - await page.getByRole("button").click(); - - const blockedModel = page.getByText("Claude Sonnet 4.6").first(); - await blockedModel.click(); - expect(mounted.onProviderModelChange).not.toHaveBeenCalled(); - expect(document.querySelector(".model-picker-list")).not.toBeNull(); - } finally { - await mounted.cleanup(); - } - }); - - it("only shows codex spark when the server reports it", async () => { - const providersWithoutSpark: ReadonlyArray = [ - buildCodexProvider([ - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - ], - }), - }, - ]), - TEST_PROVIDERS[1]!, - ]; - const providersWithSpark: ReadonlyArray = [ - buildCodexProvider([ - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - ], - }), - }, - { - slug: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoningEffort", "Reasoning", [ - { id: "low", label: "low" }, - { id: "medium", label: "medium", isDefault: true }, - { id: "high", label: "high" }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - ], - }), - }, - ]), - TEST_PROVIDERS[1]!, - ]; - - const hidden = await mountPicker({ - model: "gpt-5.3-codex", - lockedProvider: null, - providers: providersWithoutSpark, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("GPT-5.3 Codex"); - expect(text).not.toContain("GPT-5.3 Codex Spark"); - }); - } finally { - await hidden.cleanup(); - } - - const visible = await mountPicker({ - model: "gpt-5.3-codex", - lockedProvider: null, - providers: providersWithSpark, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); - }); - } finally { - await visible.cleanup(); - } - }); - - it("shows disabled providers grayed out in sidebar", async () => { - const disabledProviders = TEST_PROVIDERS.slice(); - const claudeIndex = disabledProviders.findIndex( - (provider) => provider.instanceId === ProviderInstanceId.make("claudeAgent"), - ); - if (claudeIndex >= 0) { - const claudeProvider = disabledProviders[claudeIndex]!; - disabledProviders[claudeIndex] = { - ...claudeProvider, - enabled: false, - status: "disabled", - }; - } - - const mounted = await mountPicker({ - model: "gpt-5-codex", - lockedProvider: null, - providers: disabledProviders, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("GPT-5 Codex"); - // Disabled provider should not have its models shown - expect(text).not.toContain("Claude Opus 4.6"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("accepts outline trigger styling", async () => { - const mounted = await mountPicker({ - model: "gpt-5-codex", - lockedProvider: null, - triggerVariant: "outline", - }); - - try { - const button = document.querySelector("button"); - if (!(button instanceof HTMLButtonElement)) { - throw new Error("Expected picker trigger button to be rendered."); - } - expect(button.className).toContain("border-input"); - expect(button.className).toContain("bg-popover"); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 7cb5158a2c3..e3463631733 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -17,7 +17,6 @@ import { getTriggerDisplayModelLabel, getTriggerDisplayModelName, } from "./providerIconUtils"; -import { setModelPickerOpen } from "../../modelPickerOpenState"; import type { ProviderInstanceEntry } from "../../providerInstances"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { @@ -79,13 +78,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { } }; - useEffect(() => { - setModelPickerOpen(isMenuOpen); - return () => { - setModelPickerOpen(false); - }; - }, [isMenuOpen]); - useEffect(() => { if (!isMenuOpen) { return; @@ -166,7 +158,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /> } > - + {activeEntry ? ( { + it("derives a stable prompt injection state for ordinary prompt edits", () => { + expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); + expect(getComposerPromptInjectionState("Ultrathink:\nInvestigate this failure")).toBe( + "ultrathink", + ); + }); + it("returns descriptor defaults when no selections are provided", () => { const state = getComposerProviderState({ provider: PROVIDER, @@ -71,7 +79,6 @@ describe("getComposerProviderState", () => { { id: "high", label: "High", isDefault: true }, ]), ]), - prompt: "", modelOptions: undefined, }); @@ -93,7 +100,6 @@ describe("getComposerProviderState", () => { ]), booleanDescriptor("fastMode"), ]), - prompt: "", modelOptions: selections(["effort", "low"], ["fastMode", true]), }); @@ -112,7 +118,6 @@ describe("getComposerProviderState", () => { selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), booleanDescriptor("fastMode"), ]), - prompt: "", modelOptions: selections(["effort", "high"], ["fastMode", false]), }); @@ -126,7 +131,6 @@ describe("getComposerProviderState", () => { provider: PROVIDER, model: MODEL, models: modelWith([booleanDescriptor("thinking")]), - prompt: "", modelOptions: selections(["effort", "max"], ["thinking", false]), }); @@ -152,7 +156,6 @@ describe("getComposerProviderState", () => { { id: "plan", label: "Plan" }, ]), ]), - prompt: "", modelOptions: selections(["agent", "plan"]), }); @@ -167,7 +170,6 @@ describe("getComposerProviderState", () => { provider: PROVIDER, model: MODEL, models: modelWith([]), - prompt: "", modelOptions: selections(["anything", "value"]), }); @@ -193,7 +195,9 @@ describe("getComposerProviderState", () => { ["ultrathink"], ), ]), - prompt: "Ultrathink:\nInvestigate this failure", + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), modelOptions: selections(["effort", "medium"]), }); @@ -212,7 +216,9 @@ describe("getComposerProviderState", () => { models: modelWith([ selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), ]), - prompt: "Ultrathink:\nInvestigate this failure", + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), modelOptions: undefined, }); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index b5cc790538d..1349e2509b7 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -21,10 +21,12 @@ export type ComposerProviderStateInput = { provider: ProviderDriverKind; model: string; models: ReadonlyArray; - prompt: string; + promptInjectionState?: ComposerPromptInjectionState; modelOptions: ReadonlyArray | null | undefined; }; +export type ComposerPromptInjectionState = "none" | "ultrathink"; + export type ComposerProviderState = { provider: ProviderDriverKind; promptEffort: string | null; @@ -46,8 +48,12 @@ type TraitsRenderInput = { onPromptChange: (prompt: string) => void; }; +export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { + return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; +} + export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - const { provider, model, models, prompt, modelOptions } = input; + const { provider, model, models, modelOptions, promptInjectionState = "none" } = input; const caps = getProviderModelCapabilities(models, model, provider); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); const primarySelectDescriptor = descriptors.find( @@ -58,7 +64,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com const promptEffort = typeof primaryValue === "string" ? primaryValue : null; const ultrathinkActive = (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && - isClaudeUltrathinkPrompt(prompt); + promptInjectionState === "ultrathink"; return { provider, diff --git a/apps/web/src/components/clerk/DesktopClerkCard.tsx b/apps/web/src/components/clerk/DesktopClerkCard.tsx deleted file mode 100644 index e2e0c4f9aad..00000000000 --- a/apps/web/src/components/clerk/DesktopClerkCard.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { ReactNode } from "react"; - -import { cn } from "../../lib/utils"; - -// Mirrors Clerk's raised card/footer/branding composition for the desktop-native flow: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardRoot.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardFooter.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardClerkAndPagesTag.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/DevModeNotice.tsx -export function DesktopClerkCard({ - children, - footerAction, -}: { - children: ReactNode; - footerAction?: ReactNode; -}) { - return ( -
-
- {children} -
-
- {footerAction ? ( -
{footerAction}
- ) : null} - -
-
- ); -} - -export function DesktopClerkHeader({ title, subtitle }: { title: string; subtitle: string }) { - return ( -
-

{title}

-

{subtitle}

-
- ); -} - -export function DesktopClerkFooterAction({ - children, - actionLabel, - onAction, -}: { - children: ReactNode; - actionLabel: string; - onAction: () => void; -}) { - return ( -

- {children} - -

- ); -} - -export function DesktopClerkAlert({ children }: { children?: ReactNode }) { - if (!children) return null; - - return ( -
- {children} -
- ); -} - -export function DesktopClerkInput({ - className, - ...props -}: React.ComponentPropsWithoutRef<"input">) { - return ( - - ); -} - -export function DesktopClerkPrimaryButton({ - children, - disabled, -}: { - children: ReactNode; - disabled?: boolean; -}) { - return ( - - ); -} - -function DesktopClerkBranding() { - const isDevelopmentMode = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.startsWith("pk_test_"); - - return ( -
- - Secured by{" "} - - clerk - - - {isDevelopmentMode ? ( - Development mode - ) : null} -
- ); -} diff --git a/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx b/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx deleted file mode 100644 index a5dc52053ec..00000000000 --- a/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import "../../index.css"; - -import { page, userEvent } from "vite-plus/test/browser"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import type { DesktopCloudAuthOAuthOption } from "../../cloud/desktopAuth"; -import { DesktopClerkSignInCard } from "./DesktopClerkSignIn"; - -const GOOGLE: DesktopCloudAuthOAuthOption = { - strategy: "oauth_google", - label: "Google", - providerId: "google", - iconUrl: null, -}; - -const PROVIDERS: readonly DesktopCloudAuthOAuthOption[] = [ - { - strategy: "oauth_apple", - label: "Apple", - providerId: "apple", - iconUrl: null, - }, - GOOGLE, - { - strategy: "oauth_microsoft", - label: "Microsoft", - providerId: "microsoft", - iconUrl: null, - }, -]; - -describe("DesktopClerkSignInCard", () => { - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("uses Clerk's compact provider grid when more than two providers are enabled", async () => { - await render( - , - ); - - expect(document.querySelectorAll('button[aria-label^="Continue with "]')).toHaveLength(3); - expect(document.body.textContent).toContain("Want early access?"); - expect(document.body.textContent).not.toContain("Continue with Google"); - }); - - it("renders a full provider label and starts OAuth for a single provider", async () => { - const onStartOAuth = vi.fn(); - await render( - , - ); - - await userEvent.click(page.getByRole("button", { name: "Continue with Google" })); - - expect(document.body.textContent).toContain("Continue with Google"); - expect(onStartOAuth).toHaveBeenCalledWith("oauth_google"); - }); -}); diff --git a/apps/web/src/components/clerk/DesktopClerkSignIn.tsx b/apps/web/src/components/clerk/DesktopClerkSignIn.tsx deleted file mode 100644 index dc8b432e1c7..00000000000 --- a/apps/web/src/components/clerk/DesktopClerkSignIn.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { LoaderCircleIcon } from "lucide-react"; - -import type { - DesktopCloudAuthOAuthOption, - DesktopCloudAuthOAuthStrategy, -} from "../../cloud/desktopAuth"; -import { cn } from "../../lib/utils"; -import { - DesktopClerkAlert, - DesktopClerkCard, - DesktopClerkFooterAction, - DesktopClerkHeader, -} from "./DesktopClerkCard"; -import { useDesktopClerkSignIn } from "./useDesktopClerkSignIn"; - -// Mirrors Clerk's compact social-button layout while delegating OAuth to the desktop bridge: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/SocialButtons.tsx -export function DesktopClerkSignIn({ onJoinWaitlist }: { onJoinWaitlist: () => void }) { - const { isStarting, oauthOptions, startingStrategy, startOAuth } = useDesktopClerkSignIn(); - - return ( - void startOAuth(strategy)} - /> - ); -} - -export function DesktopClerkSignInCard({ - isStarting, - oauthOptions, - startingStrategy, - onJoinWaitlist, - onStartOAuth, -}: { - isStarting: boolean; - oauthOptions: readonly DesktopCloudAuthOAuthOption[]; - startingStrategy: DesktopCloudAuthOAuthStrategy | null; - onJoinWaitlist: () => void; - onStartOAuth: (strategy: DesktopCloudAuthOAuthStrategy) => void; -}) { - return ( - - Want early access? - - } - > - - {oauthOptions.length === 0 ? ( - No OAuth providers are enabled for desktop sign-in. - ) : ( - - )} - - ); -} - -function DesktopClerkSocialButtons({ - isStarting, - oauthOptions, - startingStrategy, - onStartOAuth, -}: { - isStarting: boolean; - oauthOptions: readonly DesktopCloudAuthOAuthOption[]; - startingStrategy: DesktopCloudAuthOAuthStrategy | null; - onStartOAuth: (strategy: DesktopCloudAuthOAuthStrategy) => void; -}) { - const useBlockButtons = oauthOptions.length <= 2; - - return ( -
- {oauthOptions.map((option) => { - const isCurrent = option.strategy === startingStrategy; - return ( - - ); - })} -
- ); -} - -function DesktopClerkProviderIcon({ option }: { option: DesktopCloudAuthOAuthOption }) { - if (!option.iconUrl) { - return ( - - {option.label.slice(0, 1).toUpperCase()} - - ); - } - - if (["apple", "github", "vercel"].includes(option.providerId)) { - return ( - - ); - } - - return ; -} diff --git a/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx b/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx deleted file mode 100644 index ec9198498df..00000000000 --- a/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useClerk } from "@clerk/react"; -import { useState } from "react"; - -import { - DesktopClerkAlert, - DesktopClerkCard, - DesktopClerkFooterAction, - DesktopClerkHeader, - DesktopClerkInput, - DesktopClerkPrimaryButton, -} from "./DesktopClerkCard"; -import { DesktopClerkSignIn } from "./DesktopClerkSignIn"; - -type DesktopClerkScreen = "waitlist" | "sign-in"; - -// Mirrors Clerk's waitlist card and form, replacing its router transition with the desktop sign-in flow: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/Waitlist/index.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/Waitlist/WaitlistForm.tsx -export function DesktopClerkWaitlist() { - const [screen, setScreen] = useState("waitlist"); - - if (screen === "sign-in") { - return setScreen("waitlist")} />; - } - - return setScreen("sign-in")} />; -} - -function DesktopClerkWaitlistForm({ onSignIn }: { onSignIn: () => void }) { - const clerk = useClerk(); - const [emailAddress, setEmailAddress] = useState(""); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const [didJoin, setDidJoin] = useState(false); - - const submitWaitlist = async (event: React.FormEvent) => { - event.preventDefault(); - setError(null); - setIsSubmitting(true); - try { - await clerk.joinWaitlist({ emailAddress }); - setDidJoin(true); - } catch (cause) { - setError(getClerkErrorMessage(cause)); - } finally { - setIsSubmitting(false); - } - }; - - if (didJoin) { - return ( - - - - ); - } - - return ( - - Already have access? - - } - > - - {error} -
- - - {isSubmitting ? "Joining the waitlist…" : "Join the waitlist"} - -
-
- ); -} - -function getClerkErrorMessage(error: unknown): string { - if (typeof error === "object" && error !== null && "errors" in error) { - const errors = (error as { errors?: Array<{ longMessage?: unknown; message?: unknown }> }) - .errors; - const firstError = errors?.[0]; - if (typeof firstError?.longMessage === "string") return firstError.longMessage; - if (typeof firstError?.message === "string") return firstError.message; - } - if (error instanceof Error && error.message) return error.message; - return "Could not join the waitlist. Please try again."; -} diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts new file mode 100644 index 00000000000..fcc660e8305 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -0,0 +1,65 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; + +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +function device(overrides: Partial = {}): RelayClientDeviceRecord { + return { + deviceId: "device-1", + label: "Julius’s iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.2.3", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: false, + notifyOnCompletion: true, + notifyOnFailure: false, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-06-21T12:00:00.000Z", + ...overrides, + }; +} + +describe("mobile client presentation", () => { + it("describes the client platform and enabled notification events", () => { + const client = device(); + + expect(mobileClientPlatformLabel(client)).toBe("iOS 18 · T3 Code 1.2.3"); + expect(mobileClientNotificationDetail(client)).toBe( + "Alerts enabled for approvals, completions.", + ); + }); + + it("distinguishes disabled notifications from an empty event selection", () => { + expect( + mobileClientNotificationDetail( + device({ notifications: { ...device().notifications, enabled: false } }), + ), + ).toBe("Push notifications are disabled on this device."); + expect( + mobileClientNotificationDetail( + device({ + notifications: { + enabled: true, + notifyOnApproval: false, + notifyOnInput: false, + notifyOnCompletion: false, + notifyOnFailure: false, + }, + }), + ), + ).toBe("Push notifications are enabled, but no alert types are selected."); + }); + + it("handles missing app versions and invalid update timestamps", () => { + expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); + expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable"); + }); +}); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts new file mode 100644 index 00000000000..5ca9595bef4 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -0,0 +1,39 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; + +const mobileClientUpdatedAtFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +const NOTIFICATION_PREFERENCES = [ + ["notifyOnApproval", "approvals"], + ["notifyOnInput", "input requests"], + ["notifyOnCompletion", "completions"], + ["notifyOnFailure", "failures"], +] as const satisfies ReadonlyArray< + readonly [keyof RelayClientDeviceRecord["notifications"], string] +>; + +export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { + return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; +} + +export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { + if (!device.notifications.enabled) { + return "Push notifications are disabled on this device."; + } + + const enabledPreferences = NOTIFICATION_PREFERENCES.flatMap(([preference, label]) => + device.notifications[preference] ? [label] : [], + ); + return enabledPreferences.length > 0 + ? `Alerts enabled for ${enabledPreferences.join(", ")}.` + : "Push notifications are enabled, but no alert types are selected."; +} + +export function mobileClientUpdatedAtLabel(updatedAt: string): string { + const date = new Date(updatedAt); + return Number.isNaN(date.getTime()) + ? "Update time unavailable" + : `Updated ${mobileClientUpdatedAtFormatter.format(date)}`; +} diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx new file mode 100644 index 00000000000..26af10ba5b8 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -0,0 +1,166 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; + +import { useManagedRelayDevices } from "../../cloud/managedRelayState"; +import { cn } from "../../lib/utils"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Skeleton } from "../ui/skeleton"; +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; + +function MobileClientStatusBadge({ + enabled, + label, +}: { + readonly enabled: boolean; + readonly label: string; +}) { + return ( + + {label}: {enabled ? "On" : "Off"} + + ); +} + +function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { + return ( +
  • +
    +
    + +
    +
    +
    +
    +

    {device.label}

    +

    {mobileClientPlatformLabel(device)}

    +
    +

    + {mobileClientUpdatedAtLabel(device.updatedAt)} +

    +
    +
    + + +
    +

    + {mobileClientNotificationDetail(device)} +

    +
    +
    +
  • + ); +} + +function MobileClientsSkeleton() { + return ( +
    + {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( +
    +
    + +
    + + +
    + + +
    +
    +
    +
    + ))} +
    + ); +} + +function EmptyMobileClients() { + return ( + + + + + + No mobile clients + + Sign in to T3 Code on your iPhone to register it for push notifications and Live + Activities. + + + + ); +} + +export function MobileClientsUserProfilePage() { + const devicesState = useManagedRelayDevices(); + const devices = devicesState.data ?? []; + const isInitialLoad = + !devicesState.accountId || (devicesState.data === null && !devicesState.error); + const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; + + return ( +
    +
    +
    +

    Mobile clients

    +

    + Devices registered to receive T3 Connect activity from your environments. +

    +
    + +
    + +
    + {devicesState.error ? ( +
    +
    +

    + Could not load mobile clients +

    +

    {devicesState.error}

    +
    + +
    + ) : null} + + {isInitialLoad ? ( + + ) : hasErrorWithoutData ? null : devices.length > 0 ? ( +
      + {devices.map((device) => ( + + ))} +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index d3f906ef414..45477ee1b7e 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,8 +1,9 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon } from "lucide-react"; +import { LogInIcon, SmartphoneIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; export function T3ConnectSidebarSignIn() { @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() { userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent", }, }} - /> + > + } + url="mobile-clients" + > + + + ); } diff --git a/apps/web/src/components/clerk/useDesktopClerkSignIn.ts b/apps/web/src/components/clerk/useDesktopClerkSignIn.ts deleted file mode 100644 index 7b58c4f1ee6..00000000000 --- a/apps/web/src/components/clerk/useDesktopClerkSignIn.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { useClerk } from "@clerk/react"; -import { useSignIn, useSignUp } from "@clerk/react/legacy"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { - type DesktopCloudAuthOAuthStrategy, - resolveDesktopCloudAuthOAuthOptions, -} from "../../cloud/desktopAuth"; -import { toastManager } from "../ui/toast"; - -// Mirrors Clerk Expo's browser-based native SSO flow, with Electron handling the external browser -// and callback transport: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/expo/src/hooks/useSSO.ts -class DesktopClerkOperationError extends Error { - override readonly cause?: unknown; - - constructor(message: string, cause?: unknown) { - super(message); - this.name = "DesktopClerkOperationError"; - this.cause = cause; - } -} - -async function runDesktopClerkOperation( - operation: () => Promise, - message: string, -): Promise { - try { - return await operation(); - } catch (cause) { - throw new DesktopClerkOperationError(message, cause); - } -} - -function desktopClerkErrorMessage(error: unknown, fallback: string): string { - if (error instanceof DesktopClerkOperationError) { - const cause = error.cause; - if (cause instanceof Error && cause.message && cause.message !== error.message) { - return `${error.message}: ${cause.message}`; - } - return error.message; - } - return error instanceof Error ? error.message : fallback; -} - -export function useDesktopClerkSignIn() { - const clerk = useClerk(); - const { setActive } = clerk; - const { isLoaded: signInLoaded, signIn } = useSignIn(); - const { isLoaded: signUpLoaded, signUp } = useSignUp(); - const [startingStrategy, setStartingStrategy] = useState( - null, - ); - const oauthOptions = resolveDesktopCloudAuthOAuthOptions(clerk); - const callbackCleanupRef = useRef<(() => void) | null>(null); - - const clearCallbackListener = useCallback(() => { - callbackCleanupRef.current?.(); - callbackCleanupRef.current = null; - }, []); - - const completeOAuthCallback = useCallback( - async (rawUrl: string) => { - if (!signInLoaded || !signIn || !signUpLoaded || !signUp) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: "Clerk is still loading. Try signing in again.", - }); - return; - } - - let rotatingTokenNonce: string | null = null; - let sessionId: string | null = null; - try { - const callbackUrl = new URL(rawUrl); - rotatingTokenNonce = callbackUrl.searchParams.get("rotating_token_nonce"); - sessionId = callbackUrl.searchParams.get("created_session_id"); - } catch { - // Handled by the explicit nonce check below. - } - if (!rotatingTokenNonce) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: - "Clerk did not return a native session nonce. Verify this redirect URL is allowlisted for native SSO redirects.", - }); - return; - } - - try { - await runDesktopClerkOperation( - () => signIn.reload({ rotatingTokenNonce }), - "Could not reload the desktop sign-in session.", - ); - sessionId = sessionId || signIn.createdSessionId; - - if (!sessionId && signIn.firstFactorVerification.status === "transferable") { - const signUpAttempt = await runDesktopClerkOperation( - () => signUp.create({ transfer: true }), - "Could not transfer the desktop sign-up session.", - ); - sessionId = signUpAttempt.createdSessionId; - } - - if (!sessionId) { - throw new DesktopClerkOperationError("Clerk did not create a desktop session."); - } - - await runDesktopClerkOperation( - () => setActive({ session: sessionId! }), - "Could not activate the desktop cloud session.", - ); - } catch (error) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: desktopClerkErrorMessage(error, "Could not complete cloud sign-in."), - }); - } - }, - [setActive, signIn, signInLoaded, signUp, signUpLoaded], - ); - - useEffect(() => { - return () => { - clearCallbackListener(); - }; - }, [clearCallbackListener]); - - const startOAuth = useCallback( - async (strategy: DesktopCloudAuthOAuthStrategy) => { - if (!signInLoaded || !signIn) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: "Clerk is still loading. Try signing in again.", - }); - return; - } - - setStartingStrategy(strategy); - clearCallbackListener(); - try { - const redirectUrl = await runDesktopClerkOperation( - () => window.desktopBridge?.createCloudAuthRequest() ?? Promise.resolve(undefined), - "Desktop auth callback is unavailable.", - ); - if (!redirectUrl) { - throw new DesktopClerkOperationError("Desktop auth callback is unavailable."); - } - - callbackCleanupRef.current = - window.desktopBridge?.onCloudAuthCallback((rawUrl) => { - clearCallbackListener(); - void completeOAuthCallback(rawUrl); - }) ?? null; - - await runDesktopClerkOperation( - () => signIn.create({ strategy, redirectUrl } as never), - "Could not create the desktop OAuth request.", - ); - const externalUrl = - signIn.firstFactorVerification.externalVerificationRedirectURL?.toString(); - if (!externalUrl) { - throw new DesktopClerkOperationError( - "Clerk did not return an external OAuth redirect URL.", - ); - } - - const opened = await runDesktopClerkOperation( - () => window.desktopBridge?.openExternal(externalUrl) ?? Promise.resolve(false), - "Could not open the system browser.", - ); - if (!opened) { - throw new DesktopClerkOperationError("Could not open the system browser."); - } - } catch (error) { - clearCallbackListener(); - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: desktopClerkErrorMessage(error, "Could not start cloud sign-in."), - }); - } finally { - setStartingStrategy(null); - } - }, - [clearCallbackListener, completeOAuthCallback, signIn, signInLoaded], - ); - - return { - isStarting: startingStrategy !== null, - oauthOptions, - startingStrategy, - startOAuth, - }; -} diff --git a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx index b38d630dfa3..05fa8250b30 100644 --- a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx +++ b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx @@ -1,29 +1,9 @@ import { useClerk } from "@clerk/react"; -import { useState } from "react"; - -import { isElectron } from "../../env"; -import { Dialog, DialogPopup } from "../ui/dialog"; -import { DesktopClerkWaitlist } from "./DesktopClerkWaitlist"; export function useT3ConnectAuthPrompt() { const clerk = useClerk(); - const [desktopAuthOpen, setDesktopAuthOpen] = useState(false); - const openAuthPrompt = () => { - if (isElectron) { - setDesktopAuthOpen(true); - return; - } clerk.openWaitlist(); }; - - const authPrompt = isElectron ? ( - - - - - - ) : null; - - return { authPrompt, openAuthPrompt }; + return { authPrompt: null, openAuthPrompt }; } diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx deleted file mode 100644 index b4bb763593f..00000000000 --- a/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import "../../index.css"; - -import { page } from "vite-plus/test/browser"; -import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { - finishRelayClientInstall, - readRelayClientInstallDialogState, - reportRelayClientInstallProgress, - requestRelayClientInstallConfirmation, - resetRelayClientInstallDialogForTests, -} from "../../cloud/relayClientInstallDialog"; -import { RelayClientInstallDialog } from "./RelayClientInstallDialog"; - -describe("RelayClientInstallDialog", () => { - beforeEach(() => { - resetRelayClientInstallDialogForTests(); - }); - - it("confirms installation and renders streamed progress", async () => { - render(); - const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); - - await expect.element(page.getByText("Install relay client?")).toBeInTheDocument(); - await expect.element(page.getByText(/version 2026\.5\.2 locally/)).toBeInTheDocument(); - - await page.getByRole("button", { name: "Download and install" }).click(); - await expect(confirmation).resolves.toBe(true); - await expect - .element(page.getByRole("heading", { name: "Installing relay client" })) - .toBeInTheDocument(); - - reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); - await expect.element(page.getByText("Downloading relay client")).toBeInTheDocument(); - await expect - .element(page.getByRole("progressbar", { name: "Relay client installation progress" })) - .toHaveAttribute("value", "3"); - - finishRelayClientInstall(); - expect(readRelayClientInstallDialogState().status).toBe("closing"); - await expect - .element(page.getByRole("heading", { name: "Installing relay client" })) - .not.toBeInTheDocument(); - expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); - }); -}); diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index 7a20badf02b..a6ff9d8c4ec 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -30,14 +30,7 @@ function getPromptErrorMessage(error: unknown): string { export function SshPasswordPromptDialog() { const [queue, setQueue] = useState([]); - const [password, setPassword] = useState(""); - const [isResponding, setIsResponding] = useState(false); - const [now, setNow] = useState(() => Date.now()); - const [responseError, setResponseError] = useState(null); const currentRequest = queue[0] ?? null; - const inputRef = useRef(null); - const isRespondingRef = useRef(false); - const formId = useId(); useEffect(() => { const bridge = window.desktopBridge; @@ -50,14 +43,39 @@ export function SshPasswordPromptDialog() { }); }, []); - useEffect(() => { - setPassword(""); - setResponseError(null); - if (!currentRequest) { - return; - } + if (!currentRequest) { + return null; + } + + return ( + { + setQueue((currentQueue) => + currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, + ); + }} + /> + ); +} + +function ActiveSshPasswordPrompt({ + request, + onRemove, +}: { + readonly request: DesktopSshPasswordPromptRequest; + readonly onRemove: (requestId: string) => void; +}) { + const [password, setPassword] = useState(""); + const [isResponding, setIsResponding] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const [responseError, setResponseError] = useState(null); + const inputRef = useRef(null); + const isRespondingRef = useRef(false); + const formId = useId(); - setNow(Date.now()); + useEffect(() => { const frame = window.requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); @@ -65,48 +83,33 @@ export function SshPasswordPromptDialog() { return () => { window.cancelAnimationFrame(frame); }; - }, [currentRequest]); + }, []); useEffect(() => { - if (!currentRequest) { - return; - } - const interval = window.setInterval(() => { setNow(Date.now()); }, 1_000); return () => { window.clearInterval(interval); }; - }, [currentRequest]); + }, []); - const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN; + const expiresAtMs = Date.parse(request.expiresAt); const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; const isExpired = remainingMs !== null && remainingMs <= 0; const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); const remainingLabel = remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); - - useEffect(() => { - if (isExpired) { - setResponseError("This SSH password prompt expired. Try connecting again."); - } - }, [isExpired]); - - const removeCurrentPrompt = (requestId: string) => { - setQueue((currentQueue) => - currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, - ); - setPassword(""); - setResponseError(null); - }; + const visibleResponseError = isExpired + ? "This SSH password prompt expired. Try connecting again." + : responseError; const respond = async (nextPassword: string | null) => { - if (!currentRequest || isRespondingRef.current) { + if (isRespondingRef.current) { return; } - const requestId = currentRequest.requestId; + const requestId = request.requestId; if (nextPassword !== null && isExpired) { setResponseError("This SSH password prompt expired. Try connecting again."); return; @@ -117,10 +120,10 @@ export function SshPasswordPromptDialog() { setResponseError(null); try { await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); - removeCurrentPrompt(requestId); + onRemove(requestId); } catch (error) { if (nextPassword === null) { - removeCurrentPrompt(requestId); + onRemove(requestId); } else { setResponseError(getPromptErrorMessage(error)); } @@ -131,9 +134,7 @@ export function SshPasswordPromptDialog() { }; const dismissExpiredPrompt = () => { - if (currentRequest) { - removeCurrentPrompt(currentRequest.requestId); - } + onRemove(request.requestId); }; const cancelPrompt = () => { @@ -144,11 +145,11 @@ export function SshPasswordPromptDialog() { void respond(null); }; - const target = currentRequest ? describeSshTarget(currentRequest) : null; + const target = describeSshTarget(request); return ( { if (!open) { cancelPrompt(); @@ -159,9 +160,8 @@ export function SshPasswordPromptDialog() { SSH Password Required - T3 needs your SSH password to connect to{" "} - {target ? {target} : "the remote host"}. The password is passed to the - local SSH process for this connection attempt and is not saved by T3 Code. + T3 needs your SSH password to connect to {target}. The password is passed + to the local SSH process for this connection attempt and is not saved by T3 Code. @@ -175,7 +175,7 @@ export function SshPasswordPromptDialog() { >
    -

    {currentRequest?.prompt}

    +

    {request.prompt}

    {remainingLabel ? ( setPassword(event.target.value)} />
    - {responseError ? ( -

    {responseError}

    + {visibleResponseError ? ( +

    {visibleResponseError}

    ) : (

    Use SSH keys to avoid repeated password prompts on new SSH sessions. diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx new file mode 100644 index 00000000000..6cea64fb570 --- /dev/null +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -0,0 +1,268 @@ +import type { + AnnotationSide, + CodeViewDiffItem, + CodeViewItem, + DiffLineAnnotation, + FileDiffMetadata, + SelectedLineRange, +} from "@pierre/diffs"; +import { CodeView, type CodeViewHandle, type CodeViewProps } from "@pierre/diffs/react"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; + +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { fnv1a32 } from "~/lib/diffRendering"; +import { + buildDiffReviewComment, + restoreDiffReviewCommentRange, + type ReviewCommentContext, +} from "~/reviewCommentContext"; + +import { LocalCommentAnnotation } from "../files/LocalCommentAnnotation"; +import { nextFileCommentId } from "../files/fileCommentAnnotations"; + +interface DiffCommentAnnotationEntry { + id: string; + kind: "draft" | "comment"; + range: SelectedLineRange; + rangeLabel: string; + text: string; +} + +interface DiffCommentAnnotationGroup { + entries: DiffCommentAnnotationEntry[]; +} + +type DiffCommentLineAnnotation = DiffLineAnnotation; +export type AnnotatableCodeViewHandle = CodeViewHandle; +const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; + +function annotationSide(range: SelectedLineRange): AnnotationSide { + return (range.endSide ?? range.side) === "deletions" ? "deletions" : "additions"; +} + +function appendAnnotationEntry( + annotations: ReadonlyArray, + range: SelectedLineRange, + entry: DiffCommentAnnotationEntry, +): DiffCommentLineAnnotation[] { + const side = annotationSide(range); + const annotationIndex = annotations.findIndex( + (annotation) => annotation.side === side && annotation.lineNumber === range.end, + ); + if (annotationIndex < 0) { + return [ + ...annotations, + { + side, + lineNumber: range.end, + metadata: { entries: [entry] }, + }, + ]; + } + return annotations.map((annotation, index) => + index === annotationIndex + ? { + ...annotation, + metadata: { entries: [...annotation.metadata.entries, entry] }, + } + : annotation, + ); +} + +interface AnnotatableCodeViewProps { + files: ReadonlyArray<{ + fileDiff: FileDiffMetadata; + filePath: string; + fileKey: string; + collapsed: boolean; + }>; + sectionId: string; + sectionTitle: string; + composerDraftTarget: ScopedThreadRef | DraftId; + options: NonNullable["options"]>; + viewerRef?: Ref; + className?: string; + renderHeaderPrefix: ( + fileDiff: FileDiffMetadata, + fileKey: string, + collapsed: boolean, + ) => ReactNode; +} + +interface DiffSelectionContext { + item: CodeViewItem; +} + +export function AnnotatableCodeView({ + files, + sectionId, + sectionTitle, + composerDraftTarget, + options, + viewerRef, + className, + renderHeaderPrefix, +}: AnnotatableCodeViewProps) { + const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); + const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); + const reviewComments = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, + ); + const [selectedLines, setSelectedLines] = useState<{ + id: string; + range: SelectedLineRange; + } | null>(null); + const [draft, setDraft] = useState<{ + fileKey: string; + annotation: DiffCommentLineAnnotation; + } | null>(null); + + const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]); + const items = useMemo[]>( + () => + files.map(({ fileDiff, filePath, fileKey, collapsed }) => { + const persisted = reviewComments + .filter( + (comment) => + comment.sectionId === sectionId && + comment.filePath === filePath && + (comment.fenceLanguage ?? "diff") === "diff", + ) + .reduce((annotations, comment) => { + const range = restoreDiffReviewCommentRange(fileDiff, comment); + if (!range) return annotations; + return appendAnnotationEntry(annotations, range, { + id: comment.id, + kind: "comment", + range, + rangeLabel: comment.rangeLabel, + text: comment.text, + }); + }, []); + const annotations = + draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted; + return { + id: fileKey, + type: "diff", + fileDiff, + annotations, + collapsed, + version: fnv1a32( + `${collapsed ? "1" : "0"}:${annotations + .flatMap((annotation) => + annotation.metadata.entries.map( + (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`, + ), + ) + .join(":")}`, + ), + }; + }), + [draft, files, reviewComments, sectionId], + ); + + const removeEntry = useCallback( + (entryId: string) => { + setSelectedLines(null); + if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) { + setDraft(null); + } else { + removeReviewComment(composerDraftTarget, entryId); + } + }, + [composerDraftTarget, draft, removeReviewComment], + ); + + const submitEntry = useCallback( + (entryId: string, text: string) => { + const entry = draft?.annotation.metadata.entries.find( + (candidate) => candidate.id === entryId, + ); + const file = draft ? filesByKey.get(draft.fileKey) : undefined; + if (!entry || !file) return; + const comment = buildDiffReviewComment({ + id: entry.id, + sectionId, + sectionTitle, + filePath: file.filePath, + fileDiff: file.fileDiff, + range: entry.range, + text, + }); + if (comment) addReviewComment(composerDraftTarget, comment); + setSelectedLines(null); + setDraft(null); + }, + [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle], + ); + + const beginComment = useCallback( + (range: SelectedLineRange | null, context: DiffSelectionContext) => { + if (!range) return; + const item = context.item; + if (item.type !== "diff") return; + const file = filesByKey.get(item.id); + if (!file) return; + const id = nextFileCommentId(); + const comment = buildDiffReviewComment({ + id, + sectionId, + sectionTitle, + filePath: file.filePath, + fileDiff: file.fileDiff, + range, + text: "", + }); + if (!comment) return; + setDraft({ + fileKey: item.id, + annotation: { + side: annotationSide(range), + lineNumber: range.end, + metadata: { + entries: [{ id, kind: "draft", range, rangeLabel: comment.rangeLabel, text: "" }], + }, + }, + }); + }, + [filesByKey, sectionId, sectionTitle], + ); + + const hasOpenComment = draft !== null; + return ( + + {...(viewerRef ? { ref: viewerRef } : {})} + {...(className ? { className } : {})} + items={items} + selectedLines={selectedLines} + onSelectedLinesChange={setSelectedLines} + options={{ + ...options, + enableGutterUtility: !hasOpenComment, + enableLineSelection: !hasOpenComment, + onLineSelectionEnd: beginComment, + }} + renderHeaderPrefix={(item) => + item.type === "diff" + ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true) + : null + } + renderAnnotation={(annotation) => ( +

    + {annotation.metadata.entries.map((entry) => ( + removeEntry(entry.id)} + onComment={(text) => submitEntry(entry.id, text)} + onDelete={() => removeEntry(entry.id)} + /> + ))} +
    + )} + /> + ); +} diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx b/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx deleted file mode 100644 index 393c0ab1634..00000000000 --- a/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import "../../index.css"; - -import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; -import { render } from "vitest-browser-react"; - -import { useComposerDraftStore } from "~/composerDraftStore"; - -import { AnnotatableFileDiff } from "./AnnotatableFileDiff"; - -function dispatchPointer(target: EventTarget, type: string, pointerId: number): void { - target.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - cancelable: true, - composed: true, - pointerId, - pointerType: "mouse", - }), - ); -} - -const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("thread-1")); - -function TestDiff() { - const fileDiff = parsePatchFiles( - [ - "diff --git a/src/app.ts b/src/app.ts", - "--- a/src/app.ts", - "+++ b/src/app.ts", - "@@ -1,3 +1,3 @@", - " one", - "-two", - "+TWO", - " three", - ].join("\n"), - "annotatable-file-diff-test", - )[0]!.files[0]!; - - return ( - null} - options={{ - diffStyle: "unified", - lineDiffType: "none", - themeType: "light", - }} - /> - ); -} - -async function getRenderedDiff() { - return vi.waitFor(() => { - const element = document.querySelector("diffs-container"); - expect(element?.shadowRoot).not.toBeNull(); - return element!; - }); -} - -describe("annotatable Pierre file diff", () => { - afterEach(() => { - document.body.innerHTML = ""; - useComposerDraftStore.getState().setReviewComments(threadRef, []); - }); - - it("creates a local annotation from the gutter and attaches it to the composer", async () => { - let screen = await render(); - - try { - const diff = await getRenderedDiff(); - const addedLineNumber = await vi.waitFor(() => { - const elements = Array.from( - diff.shadowRoot?.querySelectorAll('[data-column-number="2"]') ?? [], - ); - const element = elements.at(-1) ?? null; - expect(element).not.toBeNull(); - return element!; - }); - - dispatchPointer(addedLineNumber, "pointerdown", 1); - dispatchPointer(addedLineNumber, "pointerup", 1); - - const textarea = page.getByRole("textbox", { name: "Comment on lines +2" }); - await expect.element(textarea).toBeVisible(); - await textarea.fill("Use the compatible value."); - await page.getByRole("button", { name: "Comment" }).click(); - - await vi.waitFor(() => { - expect( - useComposerDraftStore.getState().getComposerDraft(threadRef)?.reviewComments, - ).toEqual([ - expect.objectContaining({ - sectionId: "turn:2", - filePath: "src/app.ts", - rangeLabel: "+2", - text: "Use the compatible value.", - diff: "@@ -0,0 +2,1 @@\n+TWO", - }), - ]); - }); - expect(document.querySelector("[data-file-comment-annotation]")?.textContent).toContain( - "Use the compatible value.", - ); - - await screen.unmount(); - screen = await render(); - await expect - .element(page.getByText("Use the compatible value.", { exact: true })) - .toBeVisible(); - } finally { - await screen.unmount(); - } - }); -}); diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx b/apps/web/src/components/diffs/AnnotatableFileDiff.tsx deleted file mode 100644 index ceb2f87785a..00000000000 --- a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import type { - AnnotationSide, - DiffLineAnnotation, - FileDiffMetadata, - SelectedLineRange, -} from "@pierre/diffs"; -import { FileDiff, type FileDiffProps } from "@pierre/diffs/react"; -import type { ScopedThreadRef } from "@t3tools/contracts"; -import { useCallback, useMemo, useState, type ReactNode } from "react"; - -import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { - buildDiffReviewComment, - restoreDiffReviewCommentRange, - type ReviewCommentContext, -} from "~/reviewCommentContext"; - -import { LocalCommentAnnotation } from "../files/LocalCommentAnnotation"; -import { nextFileCommentId } from "../files/fileCommentAnnotations"; - -interface DiffCommentAnnotationEntry { - id: string; - kind: "draft" | "comment"; - range: SelectedLineRange; - rangeLabel: string; - text: string; -} - -interface DiffCommentAnnotationGroup { - entries: DiffCommentAnnotationEntry[]; -} - -type DiffCommentLineAnnotation = DiffLineAnnotation; -const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; - -function annotationSide(range: SelectedLineRange): AnnotationSide { - return (range.endSide ?? range.side) === "deletions" ? "deletions" : "additions"; -} - -function appendAnnotationEntry( - annotations: ReadonlyArray, - range: SelectedLineRange, - entry: DiffCommentAnnotationEntry, -): DiffCommentLineAnnotation[] { - const side = annotationSide(range); - const annotationIndex = annotations.findIndex( - (annotation) => annotation.side === side && annotation.lineNumber === range.end, - ); - if (annotationIndex < 0) { - return [ - ...annotations, - { - side, - lineNumber: range.end, - metadata: { entries: [entry] }, - }, - ]; - } - return annotations.map((annotation, index) => - index === annotationIndex - ? { - ...annotation, - metadata: { entries: [...annotation.metadata.entries, entry] }, - } - : annotation, - ); -} - -interface AnnotatableFileDiffProps { - fileDiff: FileDiffMetadata; - filePath: string; - sectionId: string; - sectionTitle: string; - composerDraftTarget: ScopedThreadRef | DraftId; - options: FileDiffProps["options"]; - renderHeaderPrefix: (fileDiff: FileDiffMetadata) => ReactNode; -} - -export function AnnotatableFileDiff({ - fileDiff, - filePath, - sectionId, - sectionTitle, - composerDraftTarget, - options, - renderHeaderPrefix, -}: AnnotatableFileDiffProps) { - const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); - const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); - const reviewComments = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, - ); - const [selectedRange, setSelectedRange] = useState(null); - const [draftAnnotation, setDraftAnnotation] = useState(null); - const persistedAnnotations = useMemo( - () => - reviewComments - .filter( - (comment) => - comment.sectionId === sectionId && - comment.filePath === filePath && - (comment.fenceLanguage ?? "diff") === "diff", - ) - .reduce((annotations, comment) => { - const range = restoreDiffReviewCommentRange(fileDiff, comment); - if (!range) return annotations; - return appendAnnotationEntry(annotations, range, { - id: comment.id, - kind: "comment", - range, - rangeLabel: comment.rangeLabel, - text: comment.text, - }); - }, []), - [fileDiff, filePath, reviewComments, sectionId], - ); - const lineAnnotations = useMemo( - () => (draftAnnotation ? [...persistedAnnotations, draftAnnotation] : persistedAnnotations), - [draftAnnotation, persistedAnnotations], - ); - - const removeAnnotationEntry = useCallback( - (entryId: string) => { - setSelectedRange(null); - if ( - draftAnnotation?.metadata.entries.some( - (entry) => entry.id === entryId && entry.kind === "draft", - ) - ) { - setDraftAnnotation(null); - return; - } - removeReviewComment(composerDraftTarget, entryId); - }, - [composerDraftTarget, draftAnnotation, removeReviewComment], - ); - - const submitAnnotationEntry = useCallback( - (entryId: string, text: string) => { - const entry = draftAnnotation?.metadata.entries.find((candidate) => candidate.id === entryId); - if (!entry) return; - - const comment = buildDiffReviewComment({ - id: entry.id, - sectionId, - sectionTitle, - filePath, - fileDiff, - range: entry.range, - text, - }); - if (comment) { - addReviewComment(composerDraftTarget, comment); - } - setSelectedRange(null); - setDraftAnnotation(null); - }, - [ - addReviewComment, - composerDraftTarget, - fileDiff, - filePath, - draftAnnotation, - sectionId, - sectionTitle, - ], - ); - - const beginComment = useCallback( - (range: SelectedLineRange) => { - const id = nextFileCommentId(); - const comment = buildDiffReviewComment({ - id, - sectionId, - sectionTitle, - filePath, - fileDiff, - range, - text: "", - }); - if (!comment) return; - - const draftEntry: DiffCommentAnnotationEntry = { - id, - kind: "draft", - range, - rangeLabel: comment.rangeLabel, - text: "", - }; - setDraftAnnotation({ - side: annotationSide(range), - lineNumber: range.end, - metadata: { entries: [draftEntry] }, - }); - }, - [fileDiff, filePath, sectionId, sectionTitle], - ); - - const hasOpenCommentForm = draftAnnotation !== null; - const handleLineSelectionEnd = useCallback( - (range: SelectedLineRange | null) => { - setSelectedRange(range); - if (range) beginComment(range); - }, - [beginComment], - ); - - return ( - - fileDiff={fileDiff} - renderHeaderPrefix={renderHeaderPrefix} - options={{ - ...options, - enableGutterUtility: !hasOpenCommentForm, - enableLineSelection: !hasOpenCommentForm, - onGutterUtilityClick: setSelectedRange, - onLineSelectionChange: setSelectedRange, - onLineSelectionEnd: handleLineSelectionEnd, - }} - selectedLines={selectedRange} - lineAnnotations={lineAnnotations} - renderAnnotation={(annotation) => ( -
    - {annotation.metadata.entries.map((entry) => ( - removeAnnotationEntry(entry.id)} - onComment={(text) => submitAnnotationEntry(entry.id, text)} - onDelete={() => removeAnnotationEntry(entry.id)} - /> - ))} -
    - )} - /> - ); -} diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.tsx deleted file mode 100644 index 7886e99cba9..00000000000 --- a/apps/web/src/components/files/FilePreviewPanel.browser.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import "../../index.css"; - -import type { LineAnnotation, SelectedLineRange } from "@pierre/diffs"; -import { Editor } from "@pierre/diffs/editor"; -import { EditorProvider, File } from "@pierre/diffs/react"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; -import { render } from "vitest-browser-react"; -import { useEffect, useMemo, useRef, useState } from "react"; - -import { installFileEditorDismissal } from "./fileEditorDismissal"; - -interface AnnotationMetadata { - label: string; -} - -function dispatchPointer(target: EventTarget, type: string, pointerId: number): void { - target.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - cancelable: true, - composed: true, - pointerId, - pointerType: "mouse", - }), - ); -} - -function EditableAnnotatedFile() { - const [selectedLines, setSelectedLines] = useState(null); - const [lineAnnotations, setLineAnnotations] = useState[]>([]); - const rootRef = useRef(null); - const editor = useMemo(() => new Editor(), []); - - useEffect(() => () => editor.cleanUp(), [editor]); - useEffect(() => { - const root = rootRef.current; - if (!root) return; - return installFileEditorDismissal({ - root, - editor, - isBlocked: () => false, - onDismiss: () => setSelectedLines(null), - }); - }, [editor]); - - return ( - <> -
    - - - file={{ name: "example.ts", contents: "one\ntwo\nthree\n" }} - options={{ - disableFileHeader: true, - enableGutterUtility: true, - enableLineSelection: true, - onGutterUtilityClick: setSelectedLines, - onLineSelectionChange: setSelectedLines, - onLineSelectionEnd: (range) => { - setSelectedLines(range); - if (range) { - setLineAnnotations([ - { - lineNumber: Math.max(range.start, range.end), - metadata: { label: `${range.start}:${range.end}` }, - }, - ]); - } - }, - }} - selectedLines={selectedLines} - lineAnnotations={lineAnnotations} - renderAnnotation={(annotation) => ( -
    - {annotation.metadata.label} -
    - )} - disableWorkerPool - contentEditable - /> -
    -
    - - - ); -} - -async function getEditableFile() { - const file = await vi.waitFor(() => { - const element = document.querySelector("diffs-container"); - expect(element?.shadowRoot).not.toBeNull(); - return element!; - }); - const content = await vi.waitFor(() => { - const element = file?.shadowRoot?.querySelector("[data-content]") ?? null; - expect(element).not.toBeNull(); - return element!; - }); - return { file, content }; -} - -describe("editable Pierre file annotations", () => { - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("keeps gutter selection and annotations enabled while the file is editable", async () => { - const screen = await render(); - - try { - const { file, content } = await getEditableFile(); - const secondLineNumber = await vi.waitFor(() => { - const element = - file?.shadowRoot?.querySelector('[data-column-number="2"]') ?? null; - expect(element).not.toBeNull(); - return element; - }); - await vi.waitFor(() => { - expect( - file?.shadowRoot?.querySelector("pre")?.hasAttribute("data-interactive-line-numbers"), - ).toBe(true); - }); - - dispatchPointer(secondLineNumber!, "pointerdown", 1); - dispatchPointer(secondLineNumber!, "pointerup", 1); - - await vi.waitFor(() => { - expect(document.querySelector("[data-test-file-annotation]")?.textContent).toBe("2:2"); - }); - - expect(content.contentEditable).toBe("true"); - expect(content.getAttribute("role")).toBe("textbox"); - } finally { - await screen.unmount(); - } - }); - - it("dismisses editor focus and selection with outside click or Escape", async () => { - const screen = await render(); - - try { - const { file, content } = await getEditableFile(); - content.focus(); - expect(file?.shadowRoot?.activeElement).toBe(content); - - await page.getByRole("button", { name: "Outside file" }).click(); - await vi.waitFor(() => { - expect(file?.shadowRoot?.activeElement).not.toBe(content); - }); - - content.focus(); - expect(file?.shadowRoot?.activeElement).toBe(content); - content.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Escape", - bubbles: true, - cancelable: true, - composed: true, - }), - ); - await vi.waitFor(() => { - expect(file?.shadowRoot?.activeElement).not.toBe(content); - }); - } finally { - await screen.unmount(); - } - }); -}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 501b8355a0e..8c430d5d2ab 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -7,15 +7,20 @@ import type { import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; -import { ensureEnvironmentApi } from "~/environmentApi"; -import { usePrimaryEnvironmentId } from "~/environments/primary/context"; +import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; +import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -26,6 +31,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment } from "~/reviewCommentContext"; +import { assetEnvironment } from "~/state/assets"; +import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; +import { previewEnvironment } from "~/state/preview"; +import { projectEnvironment } from "~/state/projects"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import FileBrowserPanel from "./FileBrowserPanel"; import { @@ -238,6 +249,7 @@ interface EditableFileSurfaceProps { contents: string; resolvedTheme: "light" | "dark"; revealRequestId: number; + wordWrap: boolean; onPostRender: FilePostRender; onPendingChange: (relativePath: string, pending: boolean) => void; } @@ -256,23 +268,22 @@ function useFileSaveCoordinator({ EditableFileSurfaceProps, "environmentId" | "cwd" | "relativePath" | "onPendingChange" >): FileSaveCoordinator { + const writeFile = useAtomCommand(projectEnvironment.writeFile); const coordinator = useMemo( () => new FileSaveCoordinator({ debounceMs: FILE_SAVE_DEBOUNCE_MS, onPendingChange: (pending) => onPendingChange(relativePath, pending), - persist: async (nextContents) => { - await ensureEnvironmentApi(environmentId).projects.writeFile({ - cwd, - relativePath, - contents: nextContents, - }); - }, + persist: (nextContents) => + writeFile({ + environmentId, + input: { cwd, relativePath, contents: nextContents }, + }), onConfirmed: (confirmedContents) => { confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); }, }), - [cwd, environmentId, onPendingChange, relativePath], + [cwd, environmentId, onPendingChange, relativePath, writeFile], ); useEffect(() => () => coordinator.dispose(), [coordinator]); @@ -287,6 +298,7 @@ function EditableFileSurface({ contents, resolvedTheme, revealRequestId, + wordWrap, onPostRender, onPendingChange, }: EditableFileSurfaceProps) { @@ -507,7 +519,7 @@ function EditableFileSurface({ onGutterUtilityClick: setSelectedRange, onLineSelectionChange: setSelectedRange, onLineSelectionEnd: handleLineSelectionEnd, - overflow: "scroll", + overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, @@ -548,7 +560,12 @@ function RenderedMarkdownSurface({ onPendingChange, }: Omit< EditableFileSurfaceProps, - "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender" + | "resolvedTheme" + | "composerDraftTarget" + | "revealLine" + | "revealRequestId" + | "wordWrap" + | "onPostRender" > & { threadRef: ScopedThreadRef; }) { @@ -582,8 +599,9 @@ function RenderedMarkdownSurface({ function initialExplorerOpen(): boolean { try { - return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false"; - } catch { + return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true; + } catch (error) { + console.error(error); return true; } } @@ -603,7 +621,15 @@ export default function FilePreviewPanel({ onPendingChange, }: FilePreviewPanelProps) { const { resolvedTheme } = useTheme(); + const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); const file = useProjectFileQuery(environmentId, cwd, relativePath); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); const [markdownView, setMarkdownView] = useState<{ @@ -636,15 +662,28 @@ export default function FilePreviewPanel({ setExplorerOpen((current) => { const next = !current; try { - window.localStorage.setItem(FILE_EXPLORER_STORAGE_KEY, String(next)); - } catch {} + setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, next, Schema.Boolean); + } catch (error) { + console.error(error); + } return next; }); }; - const handleOpenInBrowser = () => { - if (!absolutePath) return; - void openFileInPreview(threadRef, absolutePath).catch((error) => { + const handleOpenInBrowser = useCallback(() => { + if (!absolutePath || !environmentHttpBaseUrl) return; + void (async () => { + const result = await openFileInPreview({ + threadRef, + filePath: absolutePath, + httpBaseUrl: environmentHttpBaseUrl, + createAssetUrl, + openPreview, + }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -652,8 +691,8 @@ export default function FilePreviewPanel({ description: error instanceof Error ? error.message : "An error occurred.", }), ); - }); - }; + })(); + }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]); return (
    @@ -693,6 +732,7 @@ export default function FilePreviewPanel({ {absolutePath && environmentId === primaryEnvironmentId ? ( diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 61c3499df3e..1acbb0c1d20 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; function deferred() { - let resolve!: () => void; - const promise = new Promise((resolvePromise) => { + let resolve!: (result: AtomCommandResult) => void; + const promise = new Promise>((resolvePromise) => { resolve = resolvePromise; }); return { promise, resolve }; @@ -17,7 +20,9 @@ describe("FileSaveCoordinator", () => { it("debounces edits and persists only the latest contents", async () => { vi.useFakeTimers(); - const persist = vi.fn<(contents: string) => Promise>().mockResolvedValue(undefined); + const persist = vi + .fn<(contents: string) => Promise>>() + .mockResolvedValue(AsyncResult.success(undefined)); const onPendingChange = vi.fn(); const onConfirmed = vi.fn(); const coordinator = new FileSaveCoordinator({ @@ -44,9 +49,9 @@ describe("FileSaveCoordinator", () => { vi.useFakeTimers(); const firstWrite = deferred(); const persist = vi - .fn<(contents: string) => Promise>() + .fn<(contents: string) => Promise>>() .mockReturnValueOnce(firstWrite.promise) - .mockResolvedValueOnce(undefined); + .mockResolvedValueOnce(AsyncResult.success(undefined)); const onPendingChange = vi.fn(); const coordinator = new FileSaveCoordinator({ debounceMs: 500, @@ -61,7 +66,7 @@ describe("FileSaveCoordinator", () => { await vi.advanceTimersByTimeAsync(500); expect(persist).toHaveBeenCalledTimes(1); - firstWrite.resolve(); + firstWrite.resolve(AsyncResult.success(undefined)); await vi.runAllTimersAsync(); expect(persist).toHaveBeenCalledTimes(2); expect(persist).toHaveBeenLastCalledWith("latest"); @@ -73,7 +78,9 @@ describe("FileSaveCoordinator", () => { const onPendingChange = vi.fn(); const coordinator = new FileSaveCoordinator({ debounceMs: 500, - persist: vi.fn().mockRejectedValue(new Error("write failed")), + persist: vi + .fn() + .mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("write failed")))), onPendingChange, onConfirmed: vi.fn(), }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index e4c50116045..138f01d360e 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -1,11 +1,13 @@ -export interface FileSaveCoordinatorOptions { +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; + +export interface FileSaveCoordinatorOptions { readonly debounceMs: number; - readonly persist: (contents: string) => Promise; + readonly persist: (contents: string) => Promise>; readonly onPendingChange: (pending: boolean) => void; readonly onConfirmed: (contents: string) => void; } -export class FileSaveCoordinator { +export class FileSaveCoordinator { private timer: ReturnType | null = null; private latestContents = ""; private latestRevision = 0; @@ -13,7 +15,7 @@ export class FileSaveCoordinator { private saving = false; private disposed = false; - constructor(private readonly options: FileSaveCoordinatorOptions) {} + constructor(private readonly options: FileSaveCoordinatorOptions) {} change(contents: string): void { this.latestContents = contents; @@ -49,12 +51,11 @@ export class FileSaveCoordinator { this.saving = true; const contents = this.latestContents; const revision = this.latestRevision; - let succeeded = false; - try { - await this.options.persist(contents); - succeeded = true; + const result = await this.options.persist(contents); + const succeeded = result._tag === "Success"; + if (succeeded) { this.options.onConfirmed(contents); - } catch {} + } this.saving = false; if (revision === this.latestRevision) { diff --git a/apps/web/src/components/files/projectFilesQueryState.test.ts b/apps/web/src/components/files/projectFilesQueryState.test.ts index f9021b5a5d7..6486e016f00 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.ts +++ b/apps/web/src/components/files/projectFilesQueryState.test.ts @@ -1,24 +1,10 @@ -import type { - EnvironmentApi, - ProjectListEntriesResult, - ProjectReadFileResult, -} from "@t3tools/contracts"; +import type { ProjectReadFileResult } from "@t3tools/contracts"; import { EnvironmentId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { - __resetEnvironmentApiOverridesForTests, - __setEnvironmentApiOverrideForTests, -} from "~/environmentApi"; -import { appAtomRegistry } from "~/rpc/atomRegistry"; - -import { - __resetProjectFileQueryDataForTests, + clearProjectFileQueryData, confirmProjectFileQueryData, - getProjectEntriesQueryAtom, - getProjectFileQueryAtom, getOptimisticProjectFileQueryData, resolveProjectFileQueryData, setProjectFileQueryData, @@ -26,64 +12,13 @@ import { const environmentId = EnvironmentId.make("environment-project-files-query-test"); -function deferred() { - let resolve!: (value: A) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - describe("project files queries", () => { afterEach(() => { - __resetProjectFileQueryDataForTests(); - __resetEnvironmentApiOverridesForTests(); + clearProjectFileQueryData(environmentId, "/repo", "convex.json"); vi.unstubAllGlobals(); }); - it("retains cached entries while explicitly revalidating", async () => { - vi.stubGlobal("window", {}); - const first = { - entries: [{ path: "README.md", kind: "file" }], - truncated: false, - } satisfies ProjectListEntriesResult; - const second = { - entries: [ - { path: "README.md", kind: "file" }, - { path: "src", kind: "directory" }, - ], - truncated: false, - } satisfies ProjectListEntriesResult; - const revalidation = deferred(); - const listEntries = vi - .fn() - .mockResolvedValueOnce(first) - .mockReturnValueOnce(revalidation.promise); - __setEnvironmentApiOverrideForTests(environmentId, { - projects: { listEntries }, - } as unknown as EnvironmentApi); - const registry = AtomRegistry.make(); - const atom = getProjectEntriesQueryAtom(environmentId, "/repo"); - - registry.get(atom); - await vi.waitFor(() => { - expect(Option.getOrNull(AsyncResult.value(registry.get(atom)))).toEqual(first); - }); - - registry.refresh(atom); - await vi.waitFor(() => expect(listEntries).toHaveBeenCalledTimes(2)); - const refreshing = registry.get(atom); - expect(refreshing.waiting).toBe(true); - expect(Option.getOrNull(AsyncResult.value(refreshing))).toEqual(first); - - revalidation.resolve(second); - await vi.waitFor(() => { - expect(Option.getOrNull(AsyncResult.value(registry.get(atom)))).toEqual(second); - }); - registry.dispose(); - }); - - it("keeps the latest optimistic draft when an older write finishes", async () => { + it("keeps the latest optimistic draft when an older write finishes", () => { vi.stubGlobal("window", {}); const initial = { relativePath: "convex.json", @@ -91,17 +26,6 @@ describe("project files queries", () => { byteLength: 20, truncated: false, } satisfies ProjectReadFileResult; - const readFile = vi.fn().mockResolvedValue(initial); - __setEnvironmentApiOverrideForTests(environmentId, { - projects: { readFile }, - } as unknown as EnvironmentApi); - const atom = getProjectFileQueryAtom(environmentId, "/repo", "convex.json"); - - appAtomRegistry.get(atom); - await vi.waitFor(() => { - expect(Option.getOrNull(AsyncResult.value(appAtomRegistry.get(atom)))).toEqual(initial); - }); - setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}'); setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"22"}'); @@ -113,14 +37,7 @@ describe("project files queries", () => { confirmProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}'), ).toBe(false); - expect( - resolveProjectFileQueryData( - environmentId, - "/repo", - "convex.json", - Option.getOrNull(AsyncResult.value(appAtomRegistry.get(atom))), - ), - ).toEqual({ + expect(resolveProjectFileQueryData(environmentId, "/repo", "convex.json", initial)).toEqual({ relativePath: "convex.json", contents: '{"nodeVersion":"22"}', byteLength: 20, diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 37a2b266357..191b97d6a96 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -1,89 +1,23 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, ProjectListEntriesResult, ProjectReadFileResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useEffect } from "react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback } from "react"; -import { ensureEnvironmentApi } from "~/environmentApi"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { projectEnvironment } from "~/state/projects"; +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; -const PROJECT_QUERY_STALE_TIME_MS = 30_000; -const PROJECT_QUERY_IDLE_TTL_MS = 5 * 60_000; const EMPTY_PROJECT_FILE_PATH = ""; -interface OptimisticProjectFile { - readonly data: ProjectReadFileResult; - readonly confirmed: boolean; +function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { + return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } -const optimisticProjectFiles = new Map(); - -class ProjectQueryError extends Data.TaggedError("ProjectQueryError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -function queryError(message: string, cause: unknown): ProjectQueryError { - return new ProjectQueryError({ message, cause }); -} - -function entriesKey(environmentId: EnvironmentId, cwd: string): string { - return [environmentId, cwd].map(encodeURIComponent).join("|"); -} - -function fileKey(environmentId: EnvironmentId, cwd: string, relativePath: string): string { - return [environmentId, cwd, relativePath].map(encodeURIComponent).join("|"); -} - -function keyParts(key: string): string[] { - return key.split("|").map(decodeURIComponent); -} - -const projectEntriesQueryAtom = Atom.family((key: string) => - Atom.make( - Effect.tryPromise({ - try: () => { - const [environmentId, cwd] = keyParts(key) as [EnvironmentId, string]; - return ensureEnvironmentApi(environmentId).projects.listEntries({ cwd }); - }, - catch: (cause) => queryError("Could not load workspace files.", cause), - }), - ).pipe( - Atom.swr({ - staleTime: PROJECT_QUERY_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PROJECT_QUERY_IDLE_TTL_MS), - Atom.withLabel(`projects:entries:${key}`), - ), -); - -const projectFileQueryAtom = Atom.family((key: string) => - Atom.make( - Effect.tryPromise({ - try: () => { - const [environmentId, cwd, relativePath] = keyParts(key) as [EnvironmentId, string, string]; - if (relativePath === EMPTY_PROJECT_FILE_PATH) return Promise.resolve(null); - return ensureEnvironmentApi(environmentId).projects.readFile({ cwd, relativePath }); - }, - catch: (cause) => queryError("Could not read workspace file.", cause), - }), - ).pipe( - Atom.swr({ - staleTime: PROJECT_QUERY_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PROJECT_QUERY_IDLE_TTL_MS), - Atom.withLabel(`projects:file:${key}`), - ), -); - interface ProjectQueryState { readonly data: A | null; readonly error: string | null; @@ -92,7 +26,7 @@ interface ProjectQueryState { } export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { - return projectEntriesQueryAtom(entriesKey(environmentId, cwd)); + return projectEnvironment.listEntries({ environmentId, input: { cwd } }); } export function getProjectFileQueryAtom( @@ -100,7 +34,10 @@ export function getProjectFileQueryAtom( cwd: string, relativePath: string | null, ) { - return projectFileQueryAtom(fileKey(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH)); + return projectEnvironment.readFile({ + environmentId, + input: { cwd, relativePath: relativePath ?? EMPTY_PROJECT_FILE_PATH }, + }); } export function setProjectFileQueryData( @@ -109,9 +46,8 @@ export function setProjectFileQueryData( relativePath: string, contents: string, ): void { - const key = fileKey(environmentId, cwd, relativePath); - optimisticProjectFiles.set(key, { - confirmed: false, + appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), { + confirmedAgainst: undefined, data: { relativePath, contents, @@ -126,7 +62,7 @@ export function getOptimisticProjectFileQueryData( cwd: string, relativePath: string, ): ProjectReadFileResult | null { - return optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath))?.data ?? null; + return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? null; } export function confirmProjectFileQueryData( @@ -135,12 +71,25 @@ export function confirmProjectFileQueryData( relativePath: string, contents: string, ): boolean { - const key = fileKey(environmentId, cwd, relativePath); - const optimisticFile = optimisticProjectFiles.get(key); + const atom = optimisticFileAtom(environmentId, cwd, relativePath); + const optimisticFile = appAtomRegistry.get(atom); if (optimisticFile?.data.contents !== contents) return false; - optimisticProjectFiles.set(key, { ...optimisticFile, confirmed: true }); - appAtomRegistry.refresh(getProjectFileQueryAtom(environmentId, cwd, relativePath)); + const queryAtom = getProjectFileQueryAtom(environmentId, cwd, relativePath); + const confirmed = { + ...optimisticFile, + confirmedAgainst: appAtomRegistry.get(queryAtom), + }; + appAtomRegistry.set(atom, confirmed); + appAtomRegistry.refresh(queryAtom); + void executeAtomQuery(appAtomRegistry, queryAtom, { + reportDefect: false, + reportFailure: false, + }).then((result) => { + if (result._tag === "Success" && appAtomRegistry.get(atom) === confirmed) { + appAtomRegistry.set(atom, null); + } + }); return true; } @@ -151,11 +100,15 @@ export function resolveProjectFileQueryData( data: ProjectReadFileResult | null, ): ProjectReadFileResult | null { if (relativePath === null) return data; - return optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath))?.data ?? data; + return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? data; } -export function __resetProjectFileQueryDataForTests(): void { - optimisticProjectFiles.clear(); +export function clearProjectFileQueryData( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +): void { + appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null); } function errorMessage(result: AsyncResult.AsyncResult): string | null { @@ -170,7 +123,8 @@ export function useProjectEntriesQuery( ): ProjectQueryState { const atom = getProjectEntriesQueryAtom(environmentId, cwd); const result = useAtomValue(atom); - const refresh = useCallback(() => appAtomRegistry.refresh(atom), [atom]); + const refreshAtom = useAtomRefresh(atom); + const refresh = useCallback(() => refreshAtom(), [refreshAtom]); return { data: Option.getOrNull(AsyncResult.value(result)), error: errorMessage(result), @@ -186,24 +140,13 @@ export function useProjectFileQuery( ): ProjectQueryState { const atom = getProjectFileQueryAtom(environmentId, cwd, relativePath); const result = useAtomValue(atom); - const refresh = useCallback(() => appAtomRegistry.refresh(atom), [atom]); + const refreshAtom = useAtomRefresh(atom); + const refresh = useCallback(() => refreshAtom(), [refreshAtom]); const data = Option.getOrNull(AsyncResult.value(result)); - const optimisticFile = - relativePath === null - ? undefined - : optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath)); - - useEffect(() => { - if ( - relativePath === null || - optimisticFile === undefined || - !optimisticFile.confirmed || - data?.contents !== optimisticFile.data.contents - ) { - return; - } - optimisticProjectFiles.delete(fileKey(environmentId, cwd, relativePath)); - }, [cwd, data?.contents, environmentId, optimisticFile, relativePath]); + const optimisticResult = useAtomValue( + optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), + ); + const optimisticFile = relativePath === null ? null : optimisticResult; return { data: optimisticFile?.data ?? data, diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index 2f12300400d..dcaab1e3aab 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -1,5 +1,6 @@ "use client"; +import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; import { MousePointer2 } from "lucide-react"; import { useEffect, useState } from "react"; @@ -16,16 +17,31 @@ export function AgentBrowserCursor(props: { }) { const { tabId, zoomFactor, controller } = props; const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); - const [active, setActive] = useState(false); + + if (!event) return null; + + return ( + + ); +} + +function AgentBrowserCursorEvent(props: { + readonly event: DesktopPreviewPointerEvent; + readonly zoomFactor: number; + readonly controller: BrowserController; +}) { + const { event, zoomFactor, controller } = props; + const [active, setActive] = useState(true); useEffect(() => { - if (!event) return; - setActive(true); const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS); return () => window.clearTimeout(timeout); - }, [event]); - - if (!event) return null; + }, []); return (
    { + it("reports ownership when the initial transport generation connects", () => { + const initial = observeAutomationOwnerConnectedGeneration(null, 1); + expect(initial).toEqual({ + nextGeneration: 1, + shouldReport: true, + }); + + const disconnected = observeAutomationOwnerConnectedGeneration(initial.nextGeneration, null); + expect(disconnected).toEqual({ + nextGeneration: 1, + shouldReport: false, + }); + + expect(observeAutomationOwnerConnectedGeneration(disconnected.nextGeneration, 2)).toEqual({ + nextGeneration: 2, + shouldReport: true, + }); + }); + + it("does not re-report for repeated connected state from the same generation", () => { + expect(observeAutomationOwnerConnectedGeneration(3, 3)).toEqual({ + nextGeneration: 3, + shouldReport: false, + }); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index c5aab637a96..2be14363624 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -1,35 +1,70 @@ "use client"; -import { scopedThreadKey } from "@t3tools/client-runtime"; -import type { - PreviewAutomationNavigateInput, - PreviewAutomationOpenInput, - PreviewAutomationRequest, - PreviewAutomationResponse, - PreviewAutomationStatus, - ScopedThreadRef, +import { useAtomValue } from "@effect/atom-react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + type PreviewAutomationNavigateInput, + type PreviewAutomationOpenInput, + type PreviewAutomationOwner as PreviewAutomationOwnerState, + type PreviewAutomationRequest, + type PreviewAutomationStatus, + type ScopedThreadRef, } from "@t3tools/contracts"; -import { useCallback, useEffect, useId, useRef } from "react"; +import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; -import { ensureEnvironmentApi } from "~/environmentApi"; -import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + subscribeThreadPreviewState, +} from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; -import { - startBrowserRecording, - stopBrowserRecording, - useBrowserRecordingStore, -} from "~/browser/browserRecording"; +import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording"; +import { previewEnvironment } from "~/state/preview"; +import { useEnvironmentConnectionState } from "~/state/environments"; +import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +import { + PreviewAutomationNavigationTimeoutError, + PreviewAutomationOperationError, + PreviewAutomationOverlayTimeoutError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationStaleOwnerError, + PreviewAutomationTargetUnavailableError, +} from "./previewAutomationErrors"; +import { + createLatestPreviewAutomationRequestHandler, + createPreviewAutomationRequestConsumerAtom, +} from "./previewAutomationRequestConsumer"; + +export function observeAutomationOwnerConnectedGeneration( + previousGeneration: number | null, + connectedGeneration: number | null, +): { + readonly nextGeneration: number | null; + readonly shouldReport: boolean; +} { + if (connectedGeneration === null) { + return { + nextGeneration: previousGeneration, + shouldReport: false, + }; + } + return { + nextGeneration: connectedGeneration, + shouldReport: previousGeneration !== connectedGeneration, + }; +} const waitForDesktopOverlay = async ( threadRef: ScopedThreadRef, + requestId: string, timeoutMs: number, ): Promise => { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); + const state = readThreadPreviewState(threadRef); const tabId = state.snapshot?.tabId; if (tabId && state.desktopOverlay && previewBridge) { const status = await previewBridge.automation.status(tabId); @@ -37,20 +72,26 @@ const waitForDesktopOverlay = async ( } await new Promise((resolve) => window.setTimeout(resolve, 50)); } - const error = new Error(`Preview webview did not register within ${timeoutMs}ms.`); - error.name = "PreviewAutomationTimeoutError"; - throw error; + throw new PreviewAutomationOverlayTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + timeoutMs, + }); }; const waitForNavigationReadiness = async ( + threadRef: ScopedThreadRef, + requestId: string, tabId: string, readiness: PreviewAutomationNavigateInput["readiness"], timeoutMs: number, ): Promise => { - if (!previewBridge || readiness === "none") return; + const targetReadiness = readiness ?? "load"; + if (!previewBridge || targetReadiness === "none") return; const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { - if (readiness === "domContentLoaded") { + if (targetReadiness === "domContentLoaded") { const readyState = await previewBridge.automation.evaluate(tabId, { expression: "document.readyState", }); @@ -61,16 +102,21 @@ const waitForNavigationReadiness = async ( } await new Promise((resolve) => window.setTimeout(resolve, 50)); } - const error = new Error(`Preview navigation did not become ready within ${timeoutMs}ms.`); - error.name = "PreviewAutomationTimeoutError"; - throw error; + throw new PreviewAutomationNavigationTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + readiness: targetReadiness, + timeoutMs, + }); }; const currentStatus = async ( threadRef: ScopedThreadRef, visible: boolean, ): Promise => { - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); + const state = readThreadPreviewState(threadRef); const tabId = state.snapshot?.tabId ?? null; if (tabId && previewBridge && state.desktopOverlay) { const status = await previewBridge.automation.status(tabId); @@ -87,225 +133,294 @@ const currentStatus = async ( }; }; -const serializeError = (error: unknown): NonNullable => { - if (error instanceof Error) { - const detail = - "detail" in error && (error as { detail?: unknown }).detail !== undefined - ? (error as { detail?: unknown }).detail - : undefined; - return { - _tag: error.name.startsWith("PreviewAutomation") - ? error.name - : "PreviewAutomationExecutionError", - message: error.message, - ...(detail === undefined ? {} : { detail }), - }; - } - return { - _tag: "PreviewAutomationExecutionError", - message: String(error), - }; -}; - export function PreviewAutomationOwner(props: { readonly threadRef: ScopedThreadRef; readonly visible: boolean; }) { const { threadRef, visible } = props; const automationClientId = useId(); - const ownerStateRef = useRef({ threadRef, visible }); - const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise>( - async () => undefined, + const initialAutomationOwner = useMemo( + () => ({ + clientId: automationClientId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId: null, + visible: false, + supportsAutomation: Boolean(previewBridge?.automation), + focusedAt: new Date().toISOString(), + }), + [automationClientId, threadRef.environmentId, threadRef.threadId], + ); + const automationRequestsAtom = previewEnvironment.automationRequests({ + environmentId: threadRef.environmentId, + input: initialAutomationOwner, + }); + const connectionState = useEnvironmentConnectionState(threadRef.environmentId).data; + const connectedGeneration = + connectionState?.phase === "connected" ? connectionState.generation : null; + const open = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const respondToAutomation = useAtomCommand( + previewEnvironment.respondToAutomation, + "preview automation response", + ); + const reportAutomationOwner = useAtomCommand( + previewEnvironment.reportAutomationOwner, + "preview automation owner report", + ); + const clearAutomationOwner = useAtomCommand( + previewEnvironment.clearAutomationOwner, + "preview automation owner clear", ); + const connectedGenerationRef = useRef(null); + const reportCurrentAutomationOwner = useEffectEvent(() => { + const state = readThreadPreviewState(threadRef); + return reportAutomationOwner({ + environmentId: threadRef.environmentId, + input: { + clientId: automationClientId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId: state.snapshot?.tabId ?? null, + visible, + supportsAutomation: Boolean(previewBridge?.automation), + focusedAt: new Date().toISOString(), + }, + }); + }); useEffect(() => { - ownerStateRef.current = { threadRef, visible }; + void reportCurrentAutomationOwner(); }, [threadRef, visible]); const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { - if (request.threadId !== threadRef.threadId) { - const error = new Error("Preview automation request targeted a stale thread owner."); - error.name = "PreviewAutomationUnavailableError"; - throw error; - } - const api = ensureEnvironmentApi(threadRef.environmentId); - const state = selectThreadPreviewState( - usePreviewStateStore.getState().byThreadKey, - threadRef, - ); - const tabId = request.tabId ?? state.snapshot?.tabId ?? null; - switch (request.operation) { - case "status": - return currentStatus(threadRef, visible); - case "open": { - const input = request.input as PreviewAutomationOpenInput; - let activeTabId = - (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; - if (!activeTabId) { - const snapshot = await api.preview.open({ - threadId: threadRef.threadId, - ...(input.url ? { url: input.url } : {}), - }); - usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); - activeTabId = snapshot.tabId; - } else if (input.url && previewBridge) { - await previewBridge.navigate(activeTabId, input.url); + let tabId = request.tabId ?? null; + try { + if (request.threadId !== threadRef.threadId) { + throw new PreviewAutomationStaleOwnerError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + expectedThreadId: threadRef.threadId, + requestedThreadId: request.threadId, + }); + } + const state = readThreadPreviewState(threadRef); + tabId = request.tabId ?? state.snapshot?.tabId ?? null; + const unavailableTarget = { + requestId: request.requestId, + operation: request.operation, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + bridgeAvailable: Boolean(previewBridge), + }; + switch (request.operation) { + case "status": + return await currentStatus(threadRef, visible); + case "open": { + const input = request.input as PreviewAutomationOpenInput; + let activeTabId = + (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + tabId = activeTabId; + if (!activeTabId) { + const result = await open({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + ...(input.url ? { url: input.url } : {}), + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const snapshot = result.value; + applyPreviewServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + tabId = activeTabId; + } else if (input.url && previewBridge) { + await previewBridge.navigate(activeTabId, input.url); + } + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } + await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); + return await currentStatus(threadRef, input.show ?? true); } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + case "navigate": { + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const input = request.input as PreviewAutomationNavigateInput; + const resolution = resolveBrowserNavigationTarget( + threadRef.environmentId, + input.target ?? { kind: "url", url: input.url! }, + ); + await previewBridge.navigate(tabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + threadRef, + request.requestId, + tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return await currentStatus(threadRef, visible); + } + case "snapshot": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.snapshot(tabId); + case "click": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.click( + tabId, + request.input as Parameters[1], + ); + case "type": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.type( + tabId, + request.input as Parameters[1], + ); + case "press": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.press( + tabId, + request.input as Parameters[1], + ); + case "scroll": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.scroll( + tabId, + request.input as Parameters[1], + ); + case "evaluate": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.evaluate( + tabId, + request.input as Parameters[1], + ); + case "waitFor": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.waitFor( + tabId, + request.input as Parameters[1], + ); + case "recordingStart": { + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const startedAt = await startBrowserRecording(tabId); + return { + tabId, + recording: true, + startedAt, + }; + } + case "recordingStop": { + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const artifact = await stopBrowserRecording(tabId); + if (!artifact) { + throw new PreviewAutomationRecordingNotActiveError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + }); + } + return artifact; } - await waitForDesktopOverlay(threadRef, request.timeoutMs); - return currentStatus(threadRef, input.show ?? true); - } - case "navigate": { - if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); - const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( - threadRef.environmentId, - input.target ?? { kind: "url", url: input.url! }, - ); - await previewBridge.navigate(tabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - tabId, - input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, - ); - return currentStatus(threadRef, visible); - } - case "snapshot": - if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); - return previewBridge.automation.snapshot(tabId); - case "click": - if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); - return previewBridge.automation.click( - tabId, - request.input as Parameters[1], - ); - case "type": - if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); - return previewBridge.automation.type( - tabId, - request.input as Parameters[1], - ); - case "press": - if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); - return previewBridge.automation.press( - tabId, - request.input as Parameters[1], - ); - case "scroll": - if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); - return previewBridge.automation.scroll( - tabId, - request.input as Parameters[1], - ); - case "evaluate": - if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); - return previewBridge.automation.evaluate( - tabId, - request.input as Parameters[1], - ); - case "waitFor": - if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); - return previewBridge.automation.waitFor( - tabId, - request.input as Parameters[1], - ); - case "recordingStart": { - if (!tabId) throw new Error("Preview tab is not initialized."); - await startBrowserRecording(tabId); - return { - tabId, - recording: true, - startedAt: useBrowserRecordingStore.getState().startedAt, - }; - } - case "recordingStop": { - if (!tabId) throw new Error("Preview tab is not initialized."); - const artifact = await stopBrowserRecording(tabId); - if (!artifact) throw new Error("No active recording exists for this preview tab."); - return artifact; } + } catch (cause) { + throw PreviewAutomationOperationError.fromCause({ + requestId: request.requestId, + operation: request.operation, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + cause, + }); } }, - [threadRef, visible], + [open, threadRef, visible], + ); + const [requestHandler] = useState(() => + createLatestPreviewAutomationRequestHandler(handleRequest), ); useEffect(() => { - handlerRef.current = handleRequest; - }, [handleRequest]); + requestHandler.set(handleRequest); + }, [handleRequest, requestHandler]); + + const automationRequestConsumerAtom = useMemo( + () => + createPreviewAutomationRequestConsumerAtom({ + requestsAtom: automationRequestsAtom, + environmentId: threadRef.environmentId, + handleRequest: requestHandler.handle, + respond: (response) => + respondToAutomation({ + environmentId: threadRef.environmentId, + input: response, + }), + label: `preview:automation-request-consumer:${automationClientId}`, + }), + [ + automationClientId, + automationRequestsAtom, + requestHandler, + respondToAutomation, + threadRef.environmentId, + ], + ); + useAtomValue(automationRequestConsumerAtom); useEffect(() => { - const api = ensureEnvironmentApi(threadRef.environmentId); - return api.preview.automation.connect( - { clientId: automationClientId }, - (request) => { - void handlerRef.current(request).then( - (result) => - api.preview.automation.respond({ - requestId: request.requestId, - ok: true, - ...(result === undefined ? {} : { result }), - }), - (error) => - api.preview.automation.respond({ - requestId: request.requestId, - ok: false, - error: serializeError(error), - }), - ); - }, - { - onResubscribe: () => { - const ownerState = ownerStateRef.current; - const state = selectThreadPreviewState( - usePreviewStateStore.getState().byThreadKey, - ownerState.threadRef, - ); - void api.preview.automation.reportOwner({ - clientId: automationClientId, - environmentId: ownerState.threadRef.environmentId, - threadId: ownerState.threadRef.threadId, - tabId: state.snapshot?.tabId ?? null, - visible: ownerState.visible, - supportsAutomation: Boolean(previewBridge?.automation), - focusedAt: new Date().toISOString(), - }); - }, - }, + const observation = observeAutomationOwnerConnectedGeneration( + connectedGenerationRef.current, + connectedGeneration, ); - }, [automationClientId, threadRef.environmentId]); + connectedGenerationRef.current = observation.nextGeneration; + if (!observation.shouldReport) return; + + void reportCurrentAutomationOwner(); + }, [connectedGeneration]); useEffect(() => { - const api = ensureEnvironmentApi(threadRef.environmentId); - const report = () => { - const state = selectThreadPreviewState( - usePreviewStateStore.getState().byThreadKey, - threadRef, - ); - void api.preview.automation.reportOwner({ - clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId: state.snapshot?.tabId ?? null, - visible, - supportsAutomation: Boolean(previewBridge?.automation), - focusedAt: new Date().toISOString(), - }); - }; - report(); + const report = () => void reportCurrentAutomationOwner(); window.addEventListener("focus", report); - const unsubscribe = usePreviewStateStore.subscribe((state, previous) => { - const key = scopedThreadKey(threadRef); - if (state.byThreadKey[key]?.snapshot?.tabId !== previous.byThreadKey[key]?.snapshot?.tabId) { + const unsubscribe = subscribeThreadPreviewState(threadRef, (state, previous) => { + if (state.snapshot?.tabId !== previous.snapshot?.tabId) { report(); } }); return () => { window.removeEventListener("focus", report); unsubscribe(); - void api.preview.automation.clearOwner({ clientId: automationClientId }); + void clearAutomationOwner({ + environmentId: threadRef.environmentId, + input: { + clientId: automationClientId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }, + }); }; - }, [automationClientId, threadRef, visible]); + }, [automationClientId, clearAutomationOwner, threadRef]); return null; } diff --git a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx deleted file mode 100644 index 8cb48c7e114..00000000000 --- a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import "../../index.css"; - -import { page } from "vite-plus/test/browser"; -import { describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { PreviewChromeRow } from "./PreviewChromeRow"; - -const defaultProps = { - url: "https://example.com/", - loading: false, - loadProgress: 0, - canGoBack: false, - canGoForward: false, - refreshDisabled: false, - onBack: vi.fn(), - onForward: vi.fn(), - onRefresh: vi.fn(), - onSubmit: vi.fn(), -}; - -describe("PreviewChromeRow", () => { - it("uses the shared compact surface subheader treatment", async () => { - const screen = await render(); - const subheader = screen.container.querySelector("[data-surface-subheader]"); - - expect(subheader).not.toBeNull(); - expect(subheader?.getBoundingClientRect().height).toBe(40); - expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); - expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); - }); - - it("only focuses the URL input after an explicit focus request", async () => { - const previouslyFocused = document.createElement("button"); - document.body.append(previouslyFocused); - previouslyFocused.focus(); - - const screen = await render(); - const input = page.getByRole("textbox").element() as HTMLInputElement; - - expect(document.activeElement).toBe(previouslyFocused); - - await screen.rerender(); - - expect(document.activeElement).toBe(input); - expect(input.selectionStart).toBe(0); - expect(input.selectionEnd).toBe(input.value.length); - - previouslyFocused.remove(); - }); - - it("shows a friendly asset label until the URL input receives focus", async () => { - const fullUrl = "http://127.0.0.1:3773/api/assets/token/report.pdf"; - await render( - , - ); - const input = page.getByRole("textbox"); - - await expect.element(input).toHaveValue("Local environment · report.pdf"); - - await input.click(); - - await expect.element(input).toHaveValue(fullUrl); - - input.element().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - - await expect.element(input).toHaveValue("Local environment · report.pdf"); - }); - - it("shows only the host for regular URLs until the input receives focus", async () => { - const fullUrl = "https://t3.chat/chat/18378834-f776-4507-ada7-6f79"; - await render(); - const input = page.getByRole("textbox"); - - await expect.element(input).toHaveValue("t3.chat"); - - await input.click(); - - await expect.element(input).toHaveValue(fullUrl); - }); -}); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 469be486ee8..a20bfaf47e9 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -87,12 +87,6 @@ export function PreviewChromeRow({ const [draft, setDraft] = useState(url); const [inputFocused, setInputFocused] = useState(false); - // Sync the input with external URL changes, but only when the user isn't - // actively typing (preserves in-progress edits during navigation events). - useEffect(() => { - setDraft((previous) => (document.activeElement === inputRef.current ? previous : url)); - }, [url]); - useEffect(() => { if (focusUrlNonce == null) return; const node = inputRef.current; @@ -171,7 +165,7 @@ export function PreviewChromeRow({ render={ inputRef.current?.select()); }} onBlur={() => { - setDraft(url); setInputFocused(false); }} onKeyDown={(event) => { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index e3d09a31961..861a8df616b 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,16 +1,17 @@ "use client"; -import { scopedThreadKey } from "@t3tools/client-runtime"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; -import { ensureEnvironmentApi } from "~/environmentApi"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; -import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; +import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import { readEnvironmentConnection } from "~/environments/runtime"; +import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; +import { previewEnvironment } from "~/state/preview"; +import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; @@ -30,7 +31,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor"; import { startBrowserRecording, stopBrowserRecording, - useBrowserRecordingStore, + useActiveBrowserRecordingTabId, } from "~/browser/browserRecording"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; @@ -50,16 +51,15 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { const [focusUrlNonce, setFocusUrlNonce] = useState(undefined); const [pickActive, setPickActive] = useState(false); - const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); + const activeRecordingTabId = useActiveBrowserRecordingTabId(); const pickActiveRef = useRef(false); const isMountedRef = useRef(true); - const previewState = usePreviewStateStore((state) => - selectThreadPreviewState(state.byThreadKey, threadRef), - ); - const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); - const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); + const previewState = useThreadPreviewState(threadRef); const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); + const environment = useEnvironment(threadRef.environmentId); + const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); + const open = useAtomCommand(previewEnvironment.open); usePreviewSession(threadRef); @@ -83,40 +83,36 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const showEmptyState = shouldShowPreviewEmptyState(snapshot); const controller = desktopOverlay?.controller ?? "none"; const loadProgress = useLoadingProgress(loading); - const environmentConnection = readEnvironmentConnection(threadRef.environmentId); const displayUrl = - url && environmentConnection + url && environment && environmentHttpBaseUrl ? (formatPreviewUrl({ url, - environmentLabel: environmentConnection.knownEnvironment.label, - environmentHttpBaseUrl: environmentConnection.knownEnvironment.target.httpBaseUrl, + environmentLabel: environment.label, + environmentHttpBaseUrl, }) ?? undefined) : undefined; const handleSubmitUrl = useCallback( async (next: string) => { - const api = ensureEnvironmentApi(threadRef.environmentId); try { const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); if (tabId && previewBridge) { // Drive the webview imperatively; `usePreviewBridge` mirrors the // resolved URL back to the server so other clients stay in sync. await previewBridge.navigate(tabId, resolvedUrl); - rememberUrl(threadRef, resolvedUrl); + rememberPreviewUrl(threadRef, resolvedUrl); } else { await openPreviewSession({ - previewApi: api.preview, + openPreview: open, threadRef, url: resolvedUrl, - applyServerSnapshot, - rememberUrl, }); } } catch { // Server-side `failed` event renders the unreachable view. } }, - [applyServerSnapshot, rememberUrl, tabId, threadRef], + [open, tabId, threadRef], ); const handleRefresh = useCallback(() => { diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts new file mode 100644 index 00000000000..5dfc1a42e9f --- /dev/null +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -0,0 +1,52 @@ +import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + resetPreviewStateForTests, +} from "~/previewStateStore"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; + +import { addBrowserSurface } from "./addBrowserSurface"; + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot = (tabId: string): PreviewSessionSnapshot => ({ + threadId: threadRef.threadId, + tabId, + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: `2026-06-18T19:00:0${tabId.at(-1) ?? "0"}.000Z`, +}); + +beforeEach(() => { + resetPreviewStateForTests(); + useRightPanelStore.setState({ byThreadKey: {} }); +}); + +describe("addBrowserSurface", () => { + it("creates another preview session when a browser tab is already active", async () => { + const first = snapshot("tab-1"); + const second = snapshot("tab-2"); + applyPreviewServerSnapshot(threadRef, first); + useRightPanelStore.getState().openBrowser(threadRef, first.tabId); + const openPreview = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(second)); + + await addBrowserSurface({ threadRef, openPreview: ({ input }) => openPreview(input) }); + + expect(openPreview).toHaveBeenCalledWith({ threadId: "thread-1" }); + expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); + expect( + selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + threadRef, + ).surfaces.map((surface) => surface.id), + ).toEqual(["browser:tab-1", "browser:tab-2"]); + }); +}); diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts new file mode 100644 index 00000000000..4eecac695ce --- /dev/null +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -0,0 +1,24 @@ +import { + mapAtomCommandResult, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { useRightPanelStore } from "~/rightPanelStore"; + +import { openPreviewSession } from "./openPreviewSession"; + +/** Creates a new browser tab. Reopening an existing tab is a separate UI action. */ +export async function addBrowserSurface(input: { + readonly threadRef: ScopedThreadRef; + readonly openPreview: OpenPreviewMutation; +}): Promise> { + const result = await openPreviewSession({ + openPreview: input.openPreview, + threadRef: input.threadRef, + }); + return mapAtomCommandResult(result, (snapshot) => { + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + }); +} diff --git a/apps/web/src/components/preview/closePreviewSession.test.ts b/apps/web/src/components/preview/closePreviewSession.test.ts new file mode 100644 index 00000000000..d61d2975a27 --- /dev/null +++ b/apps/web/src/components/preview/closePreviewSession.test.ts @@ -0,0 +1,79 @@ +import type { + PreviewCloseInput, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + resetPreviewStateForTests, +} from "~/previewStateStore"; + +import { closePreviewSession } from "./closePreviewSession"; + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { + _tag: "Success", + url: "http://localhost:3000/", + title: "Local app", + }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-18T19:00:00.000Z", +}; + +beforeEach(resetPreviewStateForTests); + +describe("closePreviewSession", () => { + it("suppresses stale server snapshots while the close is in flight", async () => { + applyPreviewServerSnapshot(threadRef, snapshot); + let finishClose: (() => void) | undefined; + const closePreview = vi.fn( + (_input: PreviewCloseInput) => + new Promise>>((resolve) => { + finishClose = () => resolve(AsyncResult.success(undefined)); + }), + ); + + const closing = closePreviewSession({ + closePreview: ({ input }) => closePreview(input), + snapshot, + tabId: snapshot.tabId, + threadRef, + }); + + expect(readThreadPreviewState(threadRef).sessions).toEqual({}); + applyPreviewServerSnapshot(threadRef, snapshot); + expect(readThreadPreviewState(threadRef).sessions).toEqual({}); + + finishClose?.(); + await closing; + expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" }); + }); + + it("restores the last snapshot when the server close fails", async () => { + applyPreviewServerSnapshot(threadRef, snapshot); + + const result = await closePreviewSession({ + closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))), + snapshot, + tabId: snapshot.tabId, + threadRef, + }); + + expect(result._tag).toBe("Failure"); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); + expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot }); + }); +}); diff --git a/apps/web/src/components/preview/closePreviewSession.ts b/apps/web/src/components/preview/closePreviewSession.ts new file mode 100644 index 00000000000..5073029f6d3 --- /dev/null +++ b/apps/web/src/components/preview/closePreviewSession.ts @@ -0,0 +1,37 @@ +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { + EnvironmentId, + PreviewCloseInput, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; + +import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore"; + +interface ClosePreviewSessionInput { + readonly closePreview: (input: { + readonly environmentId: EnvironmentId; + readonly input: PreviewCloseInput; + }) => Promise>; + readonly snapshot: PreviewSessionSnapshot | null; + readonly tabId: string; + readonly threadRef: ScopedThreadRef; +} + +/** + * Optimistically closes a preview while suppressing stale list responses for + * the same tab. A failed close restores the last known snapshot. + */ +export async function closePreviewSession( + input: ClosePreviewSessionInput, +): Promise> { + beginPreviewSessionClose(input.threadRef, input.tabId); + const result = await input.closePreview({ + environmentId: input.threadRef.environmentId, + input: { threadId: input.threadRef.threadId, tabId: input.tabId }, + }); + if (result._tag === "Failure") { + cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId); + } + return result; +} diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 226b6548924..664c2e33a5c 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -1,24 +1,26 @@ import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts"; +import { + mapAtomCommandResult, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import { ensureEnvironmentApi } from "~/environmentApi"; -import { usePreviewStateStore } from "~/previewStateStore"; +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; -export async function openDiscoveredPort(input: { +export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; -}): Promise { - const api = ensureEnvironmentApi(input.threadRef.environmentId); + readonly openPreview: OpenPreviewMutation; +}): Promise> { const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); - const previewState = usePreviewStateStore.getState(); - const snapshot = await openPreviewSession({ - previewApi: api.preview, + const result = await openPreviewSession({ + openPreview: input.openPreview, threadRef: input.threadRef, url: resolvedUrl, - applyServerSnapshot: previewState.applyServerSnapshot, - rememberUrl: previewState.rememberUrl, }); - useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + return mapAtomCommandResult(result, (snapshot) => { + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + }); } diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 98ad7be9a86..2e84fec5e68 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,5 +1,9 @@ -import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; +import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -21,22 +25,52 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-11T23:00:00.000Z", }; +beforeEach(resetPreviewStateForTests); + describe("openPreviewSession", () => { + it("creates an idle tab without recording a recently visited URL", async () => { + const idleSnapshot: PreviewSessionSnapshot = { + ...snapshot, + tabId: "tab-blank", + navStatus: { _tag: "Idle" }, + }; + const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(idleSnapshot)); + + await openPreviewSession({ + openPreview: ({ input }) => open(input), + threadRef, + }); + + expect(open).toHaveBeenCalledWith({ threadId: "thread-1" }); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); + }); + it("applies the RPC response without waiting for a preview event", async () => { - const open = vi.fn(async () => snapshot); - const applyServerSnapshot = vi.fn(); - const rememberUrl = vi.fn(); + const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(snapshot)); await openPreviewSession({ - previewApi: { open } as Pick, + openPreview: ({ input }) => open(input), threadRef, url: "t3.chat", - applyServerSnapshot, - rememberUrl, }); expect(open).toHaveBeenCalledWith({ threadId: "thread-1", url: "t3.chat" }); - expect(applyServerSnapshot).toHaveBeenCalledWith(threadRef, snapshot); - expect(rememberUrl).toHaveBeenCalledWith(threadRef, "https://t3.chat/"); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); + }); + + it("returns failures without mutating preview state", async () => { + const failure = new Error("preview unavailable"); + + const result = await openPreviewSession({ + openPreview: async () => AsyncResult.failure(Cause.fail(failure)), + threadRef, + url: "t3.chat", + }); + + expect(result._tag).toBe("Failure"); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); }); }); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index e33361057ce..f86ea31a187 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -1,26 +1,42 @@ -import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import type { + EnvironmentId, + PreviewOpenInput, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import type { PreviewStateStoreState } from "~/previewStateStore"; +import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; -interface OpenPreviewSessionInput { - previewApi: Pick; +interface OpenPreviewSessionInput { + openPreview: (input: { + readonly environmentId: EnvironmentId; + readonly input: PreviewOpenInput; + }) => Promise>; threadRef: ScopedThreadRef; - url: string; - applyServerSnapshot: PreviewStateStoreState["applyServerSnapshot"]; - rememberUrl: PreviewStateStoreState["rememberUrl"]; + url?: string; } -export async function openPreviewSession( - input: OpenPreviewSessionInput, -): Promise { - const snapshot = await input.previewApi.open({ - threadId: input.threadRef.threadId, - url: input.url, +export async function openPreviewSession( + input: OpenPreviewSessionInput, +): Promise> { + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { + threadId: input.threadRef.threadId, + ...(input.url === undefined ? {} : { url: input.url }), + }, }); - input.applyServerSnapshot(input.threadRef, snapshot); - input.rememberUrl( - input.threadRef, - snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, - ); - return snapshot; + if (result._tag === "Failure") { + return result; + } + const snapshot = result.value; + applyPreviewServerSnapshot(input.threadRef, snapshot); + if (input.url !== undefined) { + rememberPreviewUrl( + input.threadRef, + snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, + ); + } + return result; } diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts new file mode 100644 index 00000000000..47f03761f6b --- /dev/null +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -0,0 +1,130 @@ +import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + openTerminalLinkInPreview, + TerminalLinkContextMenuShowError, + TerminalLinkPreviewOpenError, +} from "./openTerminalLinkInPreview"; + +vi.mock("~/previewStateStore", () => ({ + applyPreviewServerSnapshot: vi.fn(), + isPreviewSupportedInRuntime: () => true, +})); + +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openBrowser: vi.fn() }), + }, +})); + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-20T00:00:00.000Z", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("openTerminalLinkInPreview", () => { + it("preserves context-menu failures with terminal link context before falling back", async () => { + const cause = new Error("menu unavailable"); + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://localhost:3000/path?token=secret", + position: { x: 12, y: 34 }, + threadRef, + openPreview, + localApi: { + contextMenu: { + show: vi.fn(async () => { + throw cause; + }), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(openPreview).not.toHaveBeenCalled(); + expect(reportError).toHaveBeenCalledOnce(); + const error = reportError.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError); + expect(error).toMatchObject({ + environmentId: "local", + threadId: "thread-1", + targetOrigin: "http://localhost:3000", + cause, + }); + expect(error.message).not.toContain("menu unavailable"); + expect(error.targetOrigin).not.toContain("secret"); + }); + + it("preserves the complete preview failure cause before falling back", async () => { + const rpcError = new Error("preview unavailable"); + const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect")); + const fallbackToBrowser = vi.fn(); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://127.0.0.1:5173/", + position: { x: 12, y: 34 }, + threadRef, + openPreview: async () => AsyncResult.failure(cause), + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(reportError).toHaveBeenCalledOnce(); + const error = reportError.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(TerminalLinkPreviewOpenError); + expect(error).toMatchObject({ + environmentId: "local", + threadId: "thread-1", + targetOrigin: "http://127.0.0.1:5173", + cause, + }); + expect(error.message).not.toContain("preview unavailable"); + }); + + it("does not report or fall back when opening the preview is interrupted", async () => { + const fallbackToBrowser = vi.fn(); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://localhost:5173/", + position: { x: 12, y: 34 }, + threadRef, + openPreview: async () => AsyncResult.failure(Cause.interrupt()), + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(reportError).not.toHaveBeenCalled(); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 0cafb439483..312eab9eb35 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -1,31 +1,48 @@ -import type { EnvironmentApi, LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { isPreviewableUrl } from "@t3tools/shared/preview"; +import * as Schema from "effect/Schema"; -import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; -interface OpenTerminalLinkInPreviewInput { +const terminalLinkErrorContext = { + environmentId: Schema.String, + threadId: Schema.String, + targetOrigin: Schema.String, + cause: Schema.Defect(), +}; + +export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass()( + "TerminalLinkContextMenuShowError", + terminalLinkErrorContext, +) { + override get message(): string { + return `Failed to show the context menu for terminal link ${this.targetOrigin}.`; + } +} + +export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass()( + "TerminalLinkPreviewOpenError", + terminalLinkErrorContext, +) { + override get message(): string { + return `Failed to open terminal link ${this.targetOrigin} in preview for thread ${this.threadId}.`; + } +} + +interface OpenTerminalLinkInPreviewInput { readonly url: string; readonly position: { x: number; y: number }; readonly threadRef: ScopedThreadRef; - readonly api: EnvironmentApi; + readonly openPreview: OpenPreviewMutation; readonly localApi: LocalApi; - /** Called whenever the URL ultimately needs to open in the system browser. */ readonly fallbackToBrowser: () => void; } -/** - * Handles a terminal-link click that resolves to a URL. - * - * - For non-loopback / unsupported runtimes, defers to the system browser. - * - For previewable URLs in the desktop build, presents a context menu to - * choose between the in-app preview and the system browser. - * - * Failures fall back to the system browser so a stuck context-menu doesn't - * leave the user without a way to open the link. - */ -export async function openTerminalLinkInPreview( - input: OpenTerminalLinkInPreviewInput, +export async function openTerminalLinkInPreview( + input: OpenTerminalLinkInPreviewInput, ): Promise { const supportsPreview = isPreviewableUrl(input.url) && @@ -37,6 +54,12 @@ export async function openTerminalLinkInPreview( return; } + const errorContext = { + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + targetOrigin: new URL(input.url).origin, + }; + let choice: "open-in-preview" | "open-in-browser" | null; try { choice = await input.localApi.contextMenu.show( @@ -46,21 +69,37 @@ export async function openTerminalLinkInPreview( ], input.position, ); - } catch { + } catch (cause) { + console.error( + new TerminalLinkContextMenuShowError({ + ...errorContext, + cause, + }), + ); input.fallbackToBrowser(); return; } if (choice === "open-in-preview") { - try { - await input.api.preview.open({ - threadId: input.threadRef.threadId, - url: input.url, - }); - useRightPanelStore.getState().open(input.threadRef, "preview"); - } catch { + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { threadId: input.threadRef.threadId, url: input.url }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return; + } + console.error( + new TerminalLinkPreviewOpenError({ + ...errorContext, + cause: result.cause, + }), + ); input.fallbackToBrowser(); + return; } + applyPreviewServerSnapshot(input.threadRef, result.value); + useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); return; } diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts new file mode 100644 index 00000000000..c4ca445458c --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -0,0 +1,169 @@ +import { + EnvironmentId, + type PreviewAutomationOwner, + PreviewAutomationOperation, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + PreviewTabId, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface PreviewAutomationOperationContext { + readonly requestId: PreviewAutomationRequest["requestId"]; + readonly operation: PreviewAutomationRequest["operation"]; + readonly environmentId: PreviewAutomationOwner["environmentId"]; + readonly threadId: PreviewAutomationRequest["threadId"]; + readonly tabId: Exclude | null; +} + +export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationOverlayTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationNavigationTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + readiness: Schema.Literals(["domContentLoaded", "load"]), + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( + "PreviewAutomationStaleOwnerError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + expectedThreadId: ThreadId, + requestedThreadId: ThreadId, + }, +) { + get responseTag() { + return "PreviewAutomationUnavailableError" as const; + } + + override get message(): string { + return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; + } +} + +export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetUnavailableError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + bridgeAvailable: Schema.Boolean, + }, +) { + get responseTag() { + return "PreviewAutomationTabNotFoundError" as const; + } + + override get message(): string { + return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; + } +} + +export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( + "PreviewAutomationRecordingNotActiveError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + }, +) { + get responseTag() { + return "PreviewAutomationExecutionError" as const; + } + + override get message(): string { + return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; + } +} + +export class PreviewAutomationOperationError extends Schema.TaggedErrorClass()( + "PreviewAutomationOperationError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + cause: Schema.Defect(), + }, +) { + static fromCause( + input: PreviewAutomationOperationContext & { readonly cause: unknown }, + ): PreviewAutomationOwnerError { + return isPreviewAutomationOwnerError(input.cause) + ? input.cause + : new PreviewAutomationOperationError(input); + } + + get responseTag() { + return "PreviewAutomationExecutionError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} failed on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}).`; + } +} + +export const PreviewAutomationOwnerError = Schema.Union([ + PreviewAutomationOverlayTimeoutError, + PreviewAutomationNavigationTimeoutError, + PreviewAutomationStaleOwnerError, + PreviewAutomationTargetUnavailableError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationOperationError, +]); +export type PreviewAutomationOwnerError = typeof PreviewAutomationOwnerError.Type; + +export const isPreviewAutomationOwnerError = Schema.is(PreviewAutomationOwnerError); + +export function serializePreviewAutomationOwnerError( + error: PreviewAutomationOwnerError, +): NonNullable { + const detail = Object.fromEntries( + Object.entries(error).filter( + ([key]) => + key !== "_tag" && key !== "cause" && key !== "name" && key !== "message" && key !== "stack", + ), + ); + return { + _tag: error.responseTag, + message: error.message, + ...(Object.keys(detail).length === 0 ? {} : { detail }), + }; +} diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts new file mode 100644 index 00000000000..905a014d5af --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -0,0 +1,196 @@ +import { + EnvironmentId, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + PreviewTabId, + ThreadId, +} from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; +import { + createPreviewAutomationRequestConsumerAtom, + serializePreviewAutomationError, +} from "./previewAutomationRequestConsumer"; + +const environmentId = EnvironmentId.make("environment-1"); +const threadId = ThreadId.make("thread-1"); +const tabId = PreviewTabId.make("tab-1"); + +const request = ( + requestId: string, + overrides: Partial = {}, +): PreviewAutomationRequest => ({ + requestId, + threadId, + operation: "status", + input: {}, + timeoutMs: 15_000, + ...overrides, +}); + +describe("previewAutomationRequestConsumer", () => { + it("consumes every request emitted before React can render", async () => { + const requestsAtom = Atom.make>( + AsyncResult.initial(false), + ); + const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({ + requestId: value.requestId, + })); + const responses: PreviewAutomationResponse[] = []; + const respond = vi.fn(async (response: PreviewAutomationResponse) => { + responses.push(response); + }); + const consumerAtom = createPreviewAutomationRequestConsumerAtom({ + requestsAtom, + environmentId, + handleRequest, + respond, + label: "test:preview-automation-consumer", + }); + const registry = AtomRegistry.make(); + registry.mount(consumerAtom); + + registry.set(requestsAtom, AsyncResult.success(request("request-1"))); + registry.set(requestsAtom, AsyncResult.success(request("request-2"))); + + await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2)); + expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([ + "request-1", + "request-2", + ]); + expect(responses.map((response) => response.requestId)).toEqual(["request-1", "request-2"]); + registry.dispose(); + }); + + it("consumes a request that arrived immediately before the consumer mounted", async () => { + const requestsAtom = Atom.make( + AsyncResult.success(request("request-ready")), + ); + const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); + const consumerAtom = createPreviewAutomationRequestConsumerAtom({ + requestsAtom, + environmentId, + handleRequest: async () => undefined, + respond, + label: "test:preview-automation-initial-request", + }); + const registry = AtomRegistry.make(); + + registry.mount(consumerAtom); + + await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1)); + expect(respond).toHaveBeenCalledWith({ requestId: "request-ready", ok: true }); + registry.dispose(); + }); + + it("preserves tagged automation errors and their structured diagnostics", () => { + const error = new PreviewAutomationTargetUnavailableError({ + requestId: "request-1", + operation: "click", + environmentId, + threadId, + tabId, + bridgeAvailable: false, + }); + + expect( + serializePreviewAutomationError(error, { + requestId: "request-1", + operation: "click", + environmentId, + threadId, + tabId, + }), + ).toEqual({ + _tag: "PreviewAutomationTabNotFoundError", + message: + "Preview automation target for click request request-1 is unavailable on environment environment-1 thread thread-1 (tab tab-1, bridge unavailable).", + detail: { + requestId: "request-1", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + bridgeAvailable: false, + }, + }); + }); + + it("correlates unexpected failures without exposing cause details", () => { + const cause = new Error("private bridge token: preview-secret"); + const context = { + requestId: "request-2", + operation: "snapshot" as const, + environmentId, + threadId, + tabId, + }; + const response = serializePreviewAutomationError(cause, context); + + expect(response).toEqual({ + _tag: "PreviewAutomationExecutionError", + message: + "Preview automation snapshot request request-2 failed on environment environment-1 thread thread-1 (tab tab-1).", + detail: { + requestId: "request-2", + operation: "snapshot", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + }, + }); + expect(JSON.stringify(response)).not.toContain("preview-secret"); + }); + + it("sanitizes unexpected handler failures at the response boundary", async () => { + const requestsAtom = Atom.make>( + AsyncResult.initial(false), + ); + const responses: PreviewAutomationResponse[] = []; + const consumerAtom = createPreviewAutomationRequestConsumerAtom({ + requestsAtom, + environmentId, + handleRequest: async () => { + throw new Error("desktop IPC secret: do-not-return"); + }, + respond: async (response) => { + responses.push(response); + }, + label: "test:preview-automation-failure-boundary", + }); + const registry = AtomRegistry.make(); + registry.mount(consumerAtom); + + registry.set( + requestsAtom, + AsyncResult.success( + request("request-failed", { + operation: "click", + tabId, + }), + ), + ); + + await vi.waitFor(() => expect(responses).toHaveLength(1)); + expect(responses[0]).toEqual({ + requestId: "request-failed", + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message: + "Preview automation click request request-failed failed on environment environment-1 thread thread-1 (tab tab-1).", + detail: { + requestId: "request-failed", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + }, + }, + }); + expect(JSON.stringify(responses[0])).not.toContain("do-not-return"); + registry.dispose(); + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts new file mode 100644 index 00000000000..37983b0255e --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts @@ -0,0 +1,87 @@ +import type { + PreviewAutomationOwner, + PreviewAutomationRequest, + PreviewAutomationResponse, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { + PreviewAutomationOperationError, + type PreviewAutomationOperationContext, + serializePreviewAutomationOwnerError, +} from "./previewAutomationErrors"; + +type AutomationRequestResult = AsyncResult.AsyncResult; +type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise; + +export function createLatestPreviewAutomationRequestHandler(initial: AutomationRequestHandler): { + readonly set: (handler: AutomationRequestHandler) => void; + readonly handle: AutomationRequestHandler; +} { + let current = initial; + return { + set: (handler) => { + current = handler; + }, + handle: (request) => current(request), + }; +} + +export function serializePreviewAutomationError( + error: unknown, + context: PreviewAutomationOperationContext, +): NonNullable { + return serializePreviewAutomationOwnerError( + PreviewAutomationOperationError.fromCause({ ...context, cause: error }), + ); +} + +export function createPreviewAutomationRequestConsumerAtom(options: { + readonly requestsAtom: Atom.Atom>; + readonly environmentId: PreviewAutomationOwner["environmentId"]; + readonly handleRequest: (request: PreviewAutomationRequest) => Promise; + readonly respond: (response: PreviewAutomationResponse) => Promise; + readonly label: string; +}): Atom.Atom { + return Atom.make((get) => { + let disposed = false; + let requestsVersion = 0; + + const consume = (result: AutomationRequestResult) => { + if (!AsyncResult.isSuccess(result)) return; + const request = result.value; + void options.handleRequest(request).then( + (value) => + options.respond({ + requestId: request.requestId, + ok: true, + ...(value === undefined ? {} : { result: value }), + }), + (error) => + options.respond({ + requestId: request.requestId, + ok: false, + error: serializePreviewAutomationError(error, { + requestId: request.requestId, + operation: request.operation, + environmentId: options.environmentId, + threadId: request.threadId, + tabId: request.tabId ?? null, + }), + }), + ); + }; + + get.addFinalizer(() => { + disposed = true; + }); + const initialRequest = get.once(options.requestsAtom); + get.subscribe(options.requestsAtom, (result) => { + requestsVersion += 1; + consume(result); + }); + queueMicrotask(() => { + if (!disposed && requestsVersion === 0) consume(initialRequest); + }); + }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label)); +} diff --git a/apps/web/src/components/preview/previewSessionState.ts b/apps/web/src/components/preview/previewSessionState.ts deleted file mode 100644 index 0896419571f..00000000000 --- a/apps/web/src/components/preview/previewSessionState.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; -import type { PreviewListResult, ScopedThreadRef } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; - -import { ensureEnvironmentApi } from "~/environmentApi"; -import { readPreviewStateRevision } from "~/previewStateStore"; -import { appAtomRegistry } from "~/rpc/atomRegistry"; - -const PREVIEW_SESSION_STALE_TIME_MS = 5_000; -const PREVIEW_SESSION_IDLE_TTL_MS = 5 * 60_000; - -class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -const previewSessionListAtom = Atom.family((threadKey: string) => - Atom.make( - Effect.tryPromise({ - try: async () => { - const threadRef = parseScopedThreadKey(threadKey); - if (!threadRef) { - throw new Error(`Invalid scoped thread key: ${threadKey}`); - } - const revision = readPreviewStateRevision(threadRef); - const result = await ensureEnvironmentApi(threadRef.environmentId).preview.list({ - threadId: threadRef.threadId, - }); - return { result, revision }; - }, - catch: (cause) => - new PreviewSessionQueryError({ - message: "Could not load preview sessions.", - cause, - }), - }), - ).pipe( - Atom.swr({ - staleTime: PREVIEW_SESSION_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PREVIEW_SESSION_IDLE_TTL_MS), - Atom.withLabel(`preview:sessions:${threadKey}`), - ), -); - -export interface PreviewSessionQueryState { - readonly data: { - readonly result: PreviewListResult; - readonly revision: number; - } | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export function refreshPreviewSessionState(threadRef: ScopedThreadRef): void { - appAtomRegistry.refresh(previewSessionListAtom(scopedThreadKey(threadRef))); -} - -export function usePreviewSessionState(threadRef: ScopedThreadRef): PreviewSessionQueryState { - const result = useAtomValue(previewSessionListAtom(scopedThreadKey(threadRef))); - let error: string | null = null; - if (result._tag === "Failure") { - const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load preview sessions."; - } - return { - data: Option.getOrNull(AsyncResult.value(result)), - error, - isPending: result.waiting, - }; -} diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 4a3bf1de931..8794ff8b487 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -9,8 +9,9 @@ import type { import { useEffect, useRef } from "react"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; -import { ensureEnvironmentApi } from "~/environmentApi"; -import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; +import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore"; +import { previewEnvironment } from "~/state/preview"; +import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; @@ -20,8 +21,8 @@ import { previewBridge } from "./previewBridge"; */ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { const { threadRef, tabId } = input; - const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); + const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report"); const bridge = previewBridge; // One bridge subscription does both jobs (mirror state + forward to @@ -31,7 +32,6 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str const lastDesktopNavStatus = useRef(null); useEffect(() => { if (!bridge || typeof window === "undefined") return; - const api = ensureEnvironmentApi(threadRef.environmentId); lastReportedUrl.current = null; lastReportedKind.current = null; lastDesktopNavStatus.current = null; @@ -41,7 +41,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str clearBrowserPointer(tabId); } lastDesktopNavStatus.current = state.navStatus; - applyDesktopState(threadRef, tabId, projectDesktopState(state)); + applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state)); const reported = buildReportInput({ threadId: threadRef.threadId, tabId, @@ -52,10 +52,13 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str if (!reported) return; lastReportedUrl.current = reported.lastReportedUrl; lastReportedKind.current = reported.lastReportedKind; - void api.preview.reportStatus(reported.input).catch(() => undefined); + void reportStatus({ + environmentId: threadRef.environmentId, + input: reported.input, + }); }); return unsubscribe; - }, [applyDesktopState, bridge, clearBrowserPointer, tabId, threadRef]); + }, [bridge, clearBrowserPointer, reportStatus, tabId, threadRef]); } function shouldClearBrowserPointer( diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index 0e24139c982..2a82f627574 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -1,110 +1,121 @@ "use client"; -import { scopedThreadKey } from "@t3tools/client-runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import { useEffect } from "react"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; -import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; -import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore"; +import { + applyPreviewServerEvent, + applyPreviewServerSnapshot, + readThreadPreviewState, +} from "~/previewStateStore"; +import { previewEnvironment } from "~/state/preview"; -import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; - -/** - * Subscribes to the server's per-thread preview events and replays the - * latest snapshot on mount. - * - * Reconnect-recovery: when the local renderer remembers a snapshot but the - * server has none (server restarted while we were alive), re-issue - * `preview.open` so subsequent events land on a real session. - */ -export function usePreviewSession(threadRef: ScopedThreadRef): void { - const query = usePreviewSessionState(threadRef); - const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); - const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); - - useEffect(() => { - // SWR retains stale data while revalidating. Do not project that stale - // snapshot back into the live store because it can resurrect a session - // that was just closed. - if ( - query.isPending || - !query.data || - query.data.revision !== readPreviewStateRevision(threadRef) - ) { - return; - } - const threadIdValue = threadRef.threadId; - let cancelled = false; - if (query.data.result.sessions.length > 0) { - for (const snapshot of query.data.result.sessions) { - applyServerSnapshot(threadRef, snapshot); - } - return; - } - - // Server has no sessions — try to recover what the renderer remembers - // from before the disconnect. - const localSnapshot = - usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; - const recoverableUrl = - localSnapshot && localSnapshot.navStatus._tag !== "Idle" ? localSnapshot.navStatus.url : null; - if (!recoverableUrl) { - applyServerSnapshot(threadRef, null); - return; - } +class PreviewSessionThreadKeyParseError extends Schema.TaggedErrorClass()( + "PreviewSessionThreadKeyParseError", + { threadKey: Schema.String }, +) { + override get message(): string { + return `Invalid scoped preview thread key: ${this.threadKey}`; + } +} - const api = ensureEnvironmentApi(threadRef.environmentId); - void api.preview - .open({ threadId: threadIdValue, url: recoverableUrl }) - .then((snapshot) => { - if (cancelled) return; - applyServerSnapshot(threadRef, snapshot); - refreshPreviewSessionState(threadRef); - }) - .catch(() => undefined); +const previewSessionSyncAtom = Atom.family((threadKey: string) => { + const threadRef = parseScopedThreadKey(threadKey); + if (threadRef === null) { + throw new PreviewSessionThreadKeyParseError({ threadKey }); + } - return () => { - cancelled = true; - }; - }, [applyServerSnapshot, query.data, query.isPending, threadRef]); + const sessionsAtom = previewEnvironment.list({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + const eventsAtom = previewEnvironment.events({ + environmentId: threadRef.environmentId, + input: {}, + }); - useEffect(() => { - if (typeof window === "undefined") return; - let clientIdentity: object | null = null; - let unsubscribeEvents: () => void = () => undefined; + return Atom.make((get) => { + let disposed = false; + let recoveryId = 0; + let recoveringUrl: string | null = null; + let sessionsVersion = 0; + let eventsVersion = 0; - const attach = () => { - const connection = readEnvironmentConnection(threadRef.environmentId); - const api = readEnvironmentApi(threadRef.environmentId); - const nextIdentity = connection?.client ?? api ?? null; - if (nextIdentity === clientIdentity) return; + const reconcileSessions = (result: Atom.Type) => { + if (!AsyncResult.isSuccess(result)) return; + if (result.value.sessions.length > 0) { + recoveringUrl = null; + recoveryId += 1; + for (const snapshot of result.value.sessions) { + applyPreviewServerSnapshot(threadRef, snapshot); + } + return; + } - unsubscribeEvents(); - unsubscribeEvents = () => undefined; - clientIdentity = nextIdentity; - if (!api) return; + const localSnapshot = readThreadPreviewState(threadRef).snapshot; + const recoverableUrl = + localSnapshot && localSnapshot.navStatus._tag !== "Idle" + ? localSnapshot.navStatus.url + : null; + if (!recoverableUrl) { + applyPreviewServerSnapshot(threadRef, null); + return; + } + if (recoveringUrl === recoverableUrl) return; - refreshPreviewSessionState(threadRef); - unsubscribeEvents = api.preview.onEvent( - (event) => { - if (event.threadId !== threadRef.threadId) return; - applyServerEvent(threadRef, event); - if (event.type === "opened" || event.type === "closed") { - refreshPreviewSessionState(threadRef); - } - }, + recoveringUrl = recoverableUrl; + const currentRecoveryId = ++recoveryId; + void runAtomCommand( + get.registry, + previewEnvironment.open, { - onResubscribe: () => refreshPreviewSessionState(threadRef), + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, url: recoverableUrl }, }, - ); + { reportDefect: false, reportFailure: false }, + ).then((openResult) => { + if (disposed || currentRecoveryId !== recoveryId) return; + recoveringUrl = null; + if (openResult._tag === "Failure") return; + applyPreviewServerSnapshot(threadRef, openResult.value); + get.refresh(sessionsAtom); + }); }; - const unsubscribeConnections = subscribeEnvironmentConnections(attach); - attach(); - return () => { - unsubscribeConnections(); - unsubscribeEvents(); + const applyLatestEvent = (result: Atom.Type) => { + if (!AsyncResult.isSuccess(result) || result.value.threadId !== threadRef.threadId) return; + applyPreviewServerEvent(threadRef, result.value); + if (result.value.type === "opened" || result.value.type === "closed") { + get.refresh(sessionsAtom); + } }; - }, [applyServerEvent, threadRef]); + + get.addFinalizer(() => { + disposed = true; + recoveryId += 1; + }); + const initialSessions = get.once(sessionsAtom); + const initialEvent = get.once(eventsAtom); + get.subscribe(sessionsAtom, (result) => { + sessionsVersion += 1; + reconcileSessions(result); + }); + get.subscribe(eventsAtom, (result) => { + eventsVersion += 1; + applyLatestEvent(result); + }); + queueMicrotask(() => { + if (disposed) return; + if (sessionsVersion === 0) reconcileSessions(initialSessions); + if (eventsVersion === 0) applyLatestEvent(initialEvent); + }); + }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`preview:session-sync:${threadKey}`)); +}); + +export function usePreviewSession(threadRef: ScopedThreadRef): void { + useAtomValue(previewSessionSyncAtom(scopedThreadKey(threadRef))); } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index affa35ff260..77c1813f110 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -2,14 +2,14 @@ import { CheckIcon } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { ProviderInstanceId, ProviderDriverKind, type ProviderInstanceConfig, } from "@t3tools/contracts"; -import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; @@ -114,15 +114,14 @@ interface AddProviderInstanceDialogProps { } export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { - const settings = useSettings(); - const { updateSettings } = useUpdateSettings(); + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); const [label, setLabel] = useState(""); const [accentColor, setAccentColor] = useState(""); - const [instanceId, setInstanceId] = useState(""); - const [instanceIdDirty, setInstanceIdDirty] = useState(false); + const [instanceIdOverride, setInstanceIdOverride] = useState(null); // Driver-specific config drafts keyed by driver so toggling between drivers // during the same dialog session does not lose in-progress input. const [configByDriver, setConfigByDriver] = useState>>({}); @@ -135,28 +134,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns [settings.providerInstances], ); - // Reset the form every time the dialog opens so each creation starts - // from a clean slate. - useEffect(() => { - if (!open) return; - setDriver(DEFAULT_DRIVER_KIND); - setLabel(""); - setAccentColor(""); - setInstanceId(""); - setWizardStep(0); - setInstanceIdDirty(false); - setConfigByDriver({}); - setHasAttemptedSubmit(false); - }, [open]); - - // Auto-derive the instance id from driver + label until the user types - // in the Instance ID field directly (after which they own its value). - useEffect(() => { - if (instanceIdDirty) return; - setInstanceId(deriveInstanceId(driver, label)); - }, [driver, label, instanceIdDirty]); - const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; + const instanceId = instanceIdOverride ?? deriveInstanceId(driver, label); const driverSettingsFields = useMemo( () => deriveProviderSettingsFields(driverOption), [driverOption], @@ -379,8 +358,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns placeholder={`${driver}_work`} value={instanceId} onChange={(event) => { - setInstanceIdDirty(true); - setInstanceId(event.target.value); + setInstanceIdOverride(event.target.value); }} aria-invalid={showInstanceIdError} /> diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b7bd306a301..6d128be192e 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -29,9 +29,20 @@ import { type DesktopServerExposureState, type EnvironmentId, } from "@t3tools/contracts"; -import { WsRpcClient } from "@t3tools/client-runtime"; +import { + connectionStatusText, + RelayConnectionRegistration, + RelayConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; @@ -76,7 +87,6 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; -import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { Group, GroupSeparator } from "../ui/group"; import { AnimatedHeight } from "../AnimatedHeight"; import { @@ -98,42 +108,43 @@ import { revokeServerClientSession, revokeServerPairingLink, isLoopbackHostname, - usePrimaryEnvironmentId, usePrimarySessionState, type ServerClientSessionRecord, type ServerPairingLinkRecord, } from "~/environments/primary"; -import { - type SavedEnvironmentRecord, - type SavedEnvironmentRuntimeState, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - addSavedEnvironment, - addManagedRelayEnvironment, - connectDesktopSshEnvironment, - disconnectSavedEnvironment, - getPrimaryEnvironmentConnection, - reconnectSavedEnvironment, - removeSavedEnvironment, -} from "~/environments/runtime"; import { useUiStateStore } from "~/uiStateStore"; import { resolveServerConfigVersionMismatch } from "~/versionSkew"; -import { useServerConfig } from "~/rpc/serverState"; -import { - connectManagedCloudEnvironment, - linkPrimaryEnvironmentToCloud, - unlinkPrimaryEnvironmentFromCloud, - updatePrimaryCloudPreferences, -} from "~/cloud/linkEnvironment"; -import { - refreshManagedRelayEnvironments, - useManagedRelayEnvironments, -} from "~/cloud/managedRelayState"; import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; -import { webRuntime } from "~/lib/runtime"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { + linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, + unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, + updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, +} from "~/cloud/linkEnvironmentAtoms"; +import { authEnvironment } from "~/state/auth"; +import { environmentCatalog } from "~/connection/catalog"; +import { + connectPairing as connectPairingAtom, + connectSshEnvironment as connectSshEnvironmentAtom, +} from "~/connection/onboarding"; +import { useEnvironmentQuery } from "~/state/query"; +import { + desktopNetworkAccessStateAtom, + refreshDesktopNetworkAccessState, +} from "~/state/desktopNetworkAccess"; +import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; +import { + type EnvironmentPresentation, + useEnvironments, + usePrimaryEnvironment, + useRelayEnvironmentDiscovery, +} from "~/state/environments"; +import { relayEnvironmentDiscovery } from "~/state/relay"; +import { useAtomCommand } from "../../state/use-atom-command"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; +const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; +const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", @@ -275,6 +286,7 @@ function ConnectionStatusDot({ const dot = (
    - - } - > - {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} - · - - - {expiresAbsolute} - +

    + {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} + · + +

    {shareablePairingUrl === null ? (

    Copy the token and pair from another client using this backend's reachable host. @@ -903,26 +846,17 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ <> {shareablePairingUrl ? ( - - - } - > - - Copy pairing URL for: {defaultEndpointCopyLabel} - - - +

    {shouldShowEndpointUrl ? ( - - - } - > - {endpoint.httpBaseUrl} - - {endpoint.httpBaseUrl} - +

    + {endpoint.httpBaseUrl} +

    ) : null} {!isAvailable ? ( @@ -1472,54 +1399,67 @@ function NetworkAccessDescription({ } type SavedBackendListRowProps = { - environmentId: EnvironmentId; - reconnectingEnvironmentId: EnvironmentId | null; - disconnectingEnvironmentId: EnvironmentId | null; + environment: EnvironmentPresentation; removingEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; - onDisconnect: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; }; function SavedBackendListRow({ - environmentId, - reconnectingEnvironmentId, - disconnectingEnvironmentId, + environment, removingEnvironmentId, onConnect, - onDisconnect, onRemove, }: SavedBackendListRowProps) { - const nowMs = useRelativeTimeTick(1_000); - const record = useSavedEnvironmentRegistryStore((state) => state.byId[environmentId] ?? null); - const runtime = useSavedEnvironmentRuntimeStore((state) => state.byId[environmentId] ?? null); - - if (!record) { - return null; - } - - const connectionState = runtime?.connectionState ?? "disconnected"; + const environmentId = environment.environmentId; + const connectionState = environment.connection.phase; const isConnected = connectionState === "connected"; - const isConnecting = - connectionState === "connecting" || reconnectingEnvironmentId === environmentId; - const isDisconnecting = disconnectingEnvironmentId === environmentId; + const isConnecting = connectionState === "connecting" || connectionState === "reconnecting"; const stateDotClassName = connectionState === "connected" ? "bg-success" - : connectionState === "connecting" + : connectionState === "connecting" || connectionState === "reconnecting" ? "bg-warning" : connectionState === "error" ? "bg-destructive" : "bg-muted-foreground/40"; - const descriptorLabel = runtime?.descriptor?.label ?? null; - const displayLabel = descriptorLabel ?? record.label; - const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); - const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); + const statusTooltip = connectionStatusText(environment.connection); + const errorTraceId = environment.connection.traceId; + const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ + target: "trace ID", + onCopy: ({ traceId }) => { + toastManager.add({ + type: "success", + title: "Trace ID copied", + description: traceId, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy trace ID", + description: error.message, + }), + ); + }, + }); + const copyTraceId = useCallback( + (traceId: string) => { + copyTraceIdToClipboard(traceId, { traceId }); + }, + [copyTraceIdToClipboard], + ); + const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); + const sshTarget = + environment.entry.target._tag === "SshConnectionTarget" && + Option.isSome(environment.entry.profile) && + environment.entry.profile.value._tag === "SshConnectionProfile" + ? environment.entry.profile.value.target + : null; const metadataBits = [ - record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, - record.lastConnectedAt - ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` - : null, + sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, + environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); return ( @@ -1531,19 +1471,15 @@ function SavedBackendListRow({ tooltipText={statusTooltip} dotClassName={stateDotClassName} pingClassName={ - connectionState === "connecting" ? "bg-warning/60 duration-2000" : null + connectionState === "connecting" || connectionState === "reconnecting" + ? "bg-warning/60 duration-2000" + : null } /> -

    {displayLabel}

    +

    {environment.label}

    - {metadataBits.length > 0 || runtime?.scopes ? ( -

    - {metadataBits.length > 0 ? metadataBits.join(" · ") : null} - {metadataBits.length > 0 && runtime?.scopes ? · : null} - {runtime?.scopes ? ( - - ) : null} -

    + {metadataBits.length > 0 ? ( +

    {metadataBits.join(" · ")}

    ) : null} {versionMismatch ? (

    @@ -1552,32 +1488,36 @@ function SavedBackendListRow({ {versionMismatch.serverVersion}.

    ) : null} + {environment.connection.error ? ( +

    + {connectionStatusText(environment.connection)} + {errorTraceId ? ( + + ) : null} +

    + ) : null}
    -
    @@ -1655,74 +1595,137 @@ function CloudLinkSwitch({ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { const { getToken, isSignedIn } = useAuth(); - const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { + reportFailure: false, + }); + const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, { + reportFailure: false, + }); + const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { + reportFailure: false, + }); + const updatePrimaryEnvironmentPreferences = useAtomCommand( + updatePrimaryEnvironmentPreferencesAtom, + { reportFailure: false }, + ); const primaryCloudLinkState = usePrimaryCloudLinkState(); const [operationError, setOperationError] = useState(null); const [isUpdating, setIsUpdating] = useState(false); const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); + const reportUpdateFailure = (cause: unknown) => { + const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); + setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); + toastManager.add({ + type: "error", + title: "Could not update T3 Connect", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); + }; + const updateLink = async (enabled: boolean) => { setIsUpdating(true); setOperationError(null); - try { - const clerkToken = await getToken(resolveRelayClerkTokenOptions()); - if (enabled) { - if (!clerkToken) { - throw new Error("Sign in to T3 Connect before linking this environment."); - } - await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken })); - } else { - await webRuntime.runPromise( - unlinkPrimaryEnvironmentFromCloud({ clerkToken: clerkToken ?? null }), - ); + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + if (tokenResult._tag === "Failure") { + reportUpdateFailure(squashAtomCommandFailure(tokenResult)); + setIsUpdating(false); + return; + } + + const target = primaryCloudLinkState.target; + if (!target) { + reportUpdateFailure(new Error("Local environment is not ready yet.")); + setIsUpdating(false); + return; + } + if (enabled && !tokenResult.value) { + reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); + setIsUpdating(false); + return; + } + + const linkResult = + enabled && tokenResult.value + ? await linkPrimaryEnvironment({ + target, + clerkToken: tokenResult.value, + }) + : await unlinkPrimaryEnvironment({ + target, + clerkToken: tokenResult.value ?? null, + }); + if (linkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(linkResult)) { + reportUpdateFailure(squashAtomCommandFailure(linkResult)); } - primaryCloudLinkState.refresh(); - refreshManagedRelayEnvironments(); - toastManager.add({ - type: "success", - title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", - description: enabled - ? "This environment is available through T3 Connect." - : "This environment is no longer available through T3 Connect.", - }); - } catch (cause) { - const message = - cause instanceof Error ? cause.message : "Could not update T3 Connect access."; - setOperationError(message); - toastManager.add({ - type: "error", - title: "Could not update T3 Connect", - description: message, - }); - } finally { setIsUpdating(false); + return; + } + + primaryCloudLinkState.refresh(); + const refreshResult = await refreshRelayEnvironments(); + if (refreshResult._tag === "Failure") { + if (!isAtomCommandInterrupted(refreshResult)) { + reportUpdateFailure(squashAtomCommandFailure(refreshResult)); + } + setIsUpdating(false); + return; } + + toastManager.add({ + type: "success", + title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", + description: enabled + ? "This environment is available through T3 Connect." + : "This environment is no longer available through T3 Connect.", + }); + setIsUpdating(false); }; + const updatePublishAgentActivity = async (enabled: boolean) => { + const target = primaryCloudLinkState.target; + if (!target) { + reportUpdateFailure(new Error("Local environment is not ready yet.")); + return; + } + setIsUpdatingPreference(true); - try { - await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); - primaryCloudLinkState.refresh(); - toastManager.add({ - type: "success", - title: enabled ? "Agent activity enabled" : "Agent activity disabled", - description: enabled - ? "This environment can publish agent activity to your notification devices." - : "This environment will stop publishing agent activity.", - }); - } catch (cause) { - toastManager.add({ - type: "error", - title: "Could not update T3 Connect preferences", - description: - cause instanceof Error ? cause.message : "Could not update agent activity publishing.", - }); - } finally { + setOperationError(null); + const updateResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: enabled, + }); + if (updateResult._tag === "Failure") { + if (!isAtomCommandInterrupted(updateResult)) { + reportUpdateFailure(squashAtomCommandFailure(updateResult)); + } setIsUpdatingPreference(false); + return; } + + primaryCloudLinkState.refresh(); + toastManager.add({ + type: "success", + title: enabled ? "Agent activity enabled" : "Agent activity disabled", + description: enabled + ? "This environment can publish agent activity to your mobile clients." + : "This environment will stop publishing agent activity.", + }); + setIsUpdatingPreference(false); }; const disabledReason = !isSignedIn - ? "Sign in to T3 Connect" + ? "Sign in to T3 Connect to manage this environment." : !canManageRelay ? "Your session does not have permission to manage T3 Connect access." : null; @@ -1742,32 +1745,27 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b { - if (!isSignedIn) { - openAuthPrompt(); - return; - } - void updateLink(enabled); - }} + onCheckedChange={(enabled) => void updateLink(enabled)} /> } /> {linked ? ( void updatePublishAgentActivity(enabled)} @@ -1775,7 +1773,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b } /> ) : null} - {authPrompt} ); } @@ -1784,13 +1781,7 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) return hasCloudPublicConfig() ? : null; } -function EmptyRemoteEnvironments({ - cloudEnabled = true, - onConnectFromCloud, -}: { - readonly cloudEnabled?: boolean; - readonly onConnectFromCloud?: () => void; -}) { +function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { return ( @@ -1799,24 +1790,9 @@ function EmptyRemoteEnvironments({ No saved remote environments - Click “Add environment” to pair another environment - {cloudEnabled ? ( - <> - , or connect one from{" "} - {onConnectFromCloud ? ( - - ) : ( - "T3 Connect" - )} - - ) : null} - . + {cloudEnabled + ? "Click “Add environment” to pair another environment, or connect one from T3 Connect." + : "Click “Add environment” to pair another environment."} @@ -1844,73 +1820,129 @@ function ConfiguredCloudRemoteEnvironmentRows({ readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironmentIds: ReadonlyArray; }) { - const { getToken, isSignedIn } = useAuth(); - const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); - const environmentsState = useManagedRelayEnvironments(); + const environmentsState = useRelayEnvironmentDiscovery(); + const registerEnvironment = useAtomCommand(environmentCatalog.register, { + reportFailure: false, + }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { + reportFailure: false, + }); + const connectRelayEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => + registerEnvironment( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: environment.environmentId, + label: environment.label, + }), + }), + ), + [registerEnvironment], + ); const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, ); const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + useEffect(() => { + void refreshRelayEnvironments(); + }, [refreshRelayEnvironments]); + const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { setConnectingEnvironmentId(environment.environmentId); - try { - const clerkToken = await getToken(resolveRelayClerkTokenOptions()); - if (!clerkToken) { - throw new Error("Sign in to T3 Connect before connecting this environment."); - } - const connection = await webRuntime.runPromise( - connectManagedCloudEnvironment({ clerkToken, environment }), - ); - await addManagedRelayEnvironment(connection); + const result = await connectRelayEnvironment(environment); + setConnectingEnvironmentId(null); + if (result._tag === "Success") { toastManager.add({ type: "success", title: "Environment connected", - description: `${connection.label} is available through T3 Connect.`, - }); - } catch (cause) { - toastManager.add({ - type: "error", - title: "Could not connect environment", - description: - cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment.", + description: `${environment.label} is available through T3 Connect.`, }); - } finally { - setConnectingEnvironmentId(null); + return; + } + if (isAtomCommandInterrupted(result)) { + return; } + const cause = squashAtomCommandFailure(result); + const message = + cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); + toastManager.add({ + type: "error", + title: "Could not connect environment", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); }; - const connectableEnvironments = (environmentsState.data ?? []).filter( - (environment) => + const connectableEnvironments = [...environmentsState.environments.values()].filter( + ({ environment }) => environment.environmentId !== primaryEnvironmentId && !savedIds.has(environment.environmentId), ); - if (savedEnvironmentIds.length === 0 && environmentsState.data === null) { + if ( + savedEnvironmentIds.length === 0 && + environmentsState.refreshing && + environmentsState.environments.size === 0 + ) { return ; } if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) { - return ( - <> - - {authPrompt} - - ); + return ; } - return connectableEnvironments.map((environment) => ( + return connectableEnvironments.map(({ environment, availability, error }) => (

    {environment.label}

    -

    T3 Connect

    +

    + {availability === "online" + ? "Available · Relay online" + : availability === "offline" + ? "Available · Relay offline" + : availability === "checking" + ? "Available · Checking relay status…" + : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} +

    @@ -803,28 +805,51 @@ function DiagnosticsRefreshButton({ } export function DiagnosticsSettingsPanel() { - const observability = useServerObservability(); - const availableEditors = useServerAvailableEditors(); + const observability = useAtomValue(primaryServerObservabilityAtom); + const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const environmentId = primaryEnvironment?.environmentId ?? null; + const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { + reportFailure: false, + }); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); const selectedResourceWindow = RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? RESOURCE_HISTORY_WINDOWS[1]; - const { data, error, isPending, refresh } = useTraceDiagnostics(); + const { data, error, isPending, refresh } = useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), + ); const { data: processData, error: processError, isPending: isProcessPending, refresh: refreshProcesses, - } = useProcessDiagnostics(); + } = useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.processDiagnostics({ environmentId, input: {} }), + ); const { data: resourceData, error: resourceError, isPending: isResourcePending, refresh: refreshResources, - } = useProcessResourceHistory({ - windowMs: selectedResourceWindow.windowMs, - bucketMs: selectedResourceWindow.bucketMs, - }); + } = useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.processResourceHistory({ + environmentId, + input: { + windowMs: selectedResourceWindow.windowMs, + bucketMs: selectedResourceWindow.bucketMs, + }, + }), + ); const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState(null); const [signalingPid, setSignalingPid] = useState(null); @@ -838,20 +863,30 @@ export function DiagnosticsSettingsPanel() { setOpenLogsDirectoryError("No available editors found."); return; } + if (environmentId === null) { + setOpenLogsDirectoryError("No environment is selected."); + return; + } setIsOpeningLogsDirectory(true); setOpenLogsDirectoryError(null); - void ensureLocalApi() - .shell.openInEditor(logsDirectoryPath, editor) - .catch((error: unknown) => { + void (async () => { + const result = await openInEditor({ + environmentId, + input: { + cwd: logsDirectoryPath, + editor, + }, + }); + setIsOpeningLogsDirectory(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); setOpenLogsDirectoryError( error instanceof Error ? error.message : "Unable to open logs folder.", ); - }) - .finally(() => { - setIsOpeningLogsDirectory(false); - }); - }, [availableEditors, observability?.logsDirectoryPath]); + } + })(); + }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]); const isInitialLoading = isPending && data === null; const isProcessInitialLoading = isProcessPending && processData === null; @@ -863,45 +898,52 @@ export function DiagnosticsSettingsPanel() { ) { return; } + if (environmentId === null) { + return; + } setSignalingPid(pid); - void ensureLocalApi() - .server.signalProcess({ pid, signal }) - .then((result) => { - if (!result.signaled) { - const message = Option.getOrUndefined(result.message); - refreshProcesses(); - if (isStaleProcessSignalMessage(message)) { - toastManager.add({ - type: "info", - title: "Process already exited", - description: - "The process is not a child of the T3 Server. It might already have exited.", - }); - return; - } - + void (async () => { + const result = await signalServerProcess({ + environmentId, + input: { pid, signal }, + }); + setSignalingPid(null); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add({ type: "error", title: `Could not send ${signal}`, - description: message ?? `Failed to send ${signal}.`, + description: error instanceof Error ? error.message : `Failed to send ${signal}.`, }); - return; } + return; + } + if (!result.value.signaled) { + const message = Option.getOrUndefined(result.value.message); refreshProcesses(); - }) - .catch((error: unknown) => { + if (isStaleProcessSignalMessage(message)) { + toastManager.add({ + type: "info", + title: "Process already exited", + description: + "The process is not a child of the T3 Server. It might already have exited.", + }); + return; + } + toastManager.add({ type: "error", title: `Could not send ${signal}`, - description: error instanceof Error ? error.message : `Failed to send ${signal}.`, + description: message ?? `Failed to send ${signal}.`, }); - }) - .finally(() => { - setSignalingPid(null); - }); + return; + } + refreshProcesses(); + })(); }, - [refreshProcesses], + [environmentId, refreshProcesses, signalServerProcess], ); const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 659823aec74..b7dbbd3575b 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -27,13 +27,23 @@ import { type ServerRemoveKeybindingInput, type ServerUpsertKeybindingInput, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../../env"; -import { openInPreferredEditor } from "../../editorPreferences"; +import { useOpenInPreferredEditor } from "../../editorPreferences"; import { formatShortcutLabel } from "../../keybindings"; import { cn } from "../../lib/utils"; -import { ensureLocalApi } from "../../localApi"; -import { useServerKeybindings, useServerKeybindingsConfigPath } from "../../rpc/serverState"; +import { + primaryServerAvailableEditorsAtom, + primaryServerKeybindingsAtom, + primaryServerKeybindingsConfigPathAtom, + serverEnvironment, +} from "../../state/server"; +import { usePrimaryEnvironment } from "../../state/environments"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; @@ -61,6 +71,7 @@ import { } from "./KeybindingsSettings.logic"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { useAtomCommand } from "../../state/use-atom-command"; function KeybindingPill({ value }: { value: string }) { const parts = value.split("+"); @@ -1069,8 +1080,20 @@ function NewKeybindingTableRow({ } export function KeybindingsSettingsPanel() { - const keybindings = useServerKeybindings(); - const keybindingsConfigPath = useServerKeybindingsConfigPath(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindingsConfigPath = useAtomValue(primaryServerKeybindingsConfigPathAtom); + const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { + reportFailure: false, + }); + const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, { + reportFailure: false, + }); + const openInPreferredEditor = useOpenInPreferredEditor( + primaryEnvironment?.environmentId ?? null, + availableEditors, + ); const [query, setQuery] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); @@ -1107,56 +1130,76 @@ export function KeybindingsSettingsPanel() { const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; - void openInPreferredEditor(ensureLocalApi(), keybindingsConfigPath).catch((error: unknown) => { + void (async () => { + const result = await openInPreferredEditor(keybindingsConfigPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); toastManager.add({ title: "Unable to open keybindings file", description: error instanceof Error ? error.message : "The keybindings file was not opened.", type: "error", }); - }); - }, [keybindingsConfigPath]); + })(); + }, [keybindingsConfigPath, openInPreferredEditor]); - const saveKeybinding = useCallback((input: ServerUpsertKeybindingInput) => { - setSavingCommand(input.command); - const payload: ServerUpsertKeybindingInput = { - command: input.command, - key: input.key.trim(), - ...(input.when?.trim() ? { when: input.when.trim() } : {}), - ...(input.replace ? { replace: input.replace } : {}), - }; - void ensureLocalApi() - .server.upsertKeybinding(payload) - .then(() => { - setIsAddingBinding(false); - }) - .catch((error: unknown) => { - toastManager.add({ - title: "Unable to save keybinding", - description: error instanceof Error ? error.message : "The keybinding was not saved.", - type: "error", + const saveKeybinding = useCallback( + (input: ServerUpsertKeybindingInput) => { + if (!primaryEnvironment) return; + setSavingCommand(input.command); + const payload: ServerUpsertKeybindingInput = { + command: input.command, + key: input.key.trim(), + ...(input.when?.trim() ? { when: input.when.trim() } : {}), + ...(input.replace ? { replace: input.replace } : {}), + }; + void (async () => { + const result = await upsertKeybinding({ + environmentId: primaryEnvironment.environmentId, + input: payload, }); - }) - .finally(() => { setSavingCommand(null); - }); - }, []); + if (result._tag === "Success") { + setIsAddingBinding(false); + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + title: "Unable to save keybinding", + description: error instanceof Error ? error.message : "The keybinding was not saved.", + type: "error", + }); + } + })(); + }, + [primaryEnvironment, upsertKeybinding], + ); - const removeKeybinding = useCallback((row: KeybindingRow) => { - setSavingCommand(row.command); - void ensureLocalApi() - .server.removeKeybinding(rowKeybindingTarget(row)) - .catch((error: unknown) => { - toastManager.add({ - title: "Unable to remove keybinding", - description: error instanceof Error ? error.message : "The keybinding was not removed.", - type: "error", + const removeKeybinding = useCallback( + (row: KeybindingRow) => { + if (!primaryEnvironment) return; + setSavingCommand(row.command); + void (async () => { + const result = await removeKeybindingMutation({ + environmentId: primaryEnvironment.environmentId, + input: rowKeybindingTarget(row), }); - }) - .finally(() => { setSavingCommand(null); - }); - }, []); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + title: "Unable to remove keybinding", + description: error instanceof Error ? error.message : "The keybinding was not removed.", + type: "error", + }); + } + })(); + }, + [primaryEnvironment, removeKeybindingMutation], + ); const resetKeybinding = useCallback( (row: KeybindingRow) => { diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 823f8f968ad..ac2f7be81e8 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { useEffect, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { isProviderDriverKind, type ProviderInstanceConfig, @@ -161,10 +161,6 @@ function ProviderEnvironmentSection(props: { props.environment.map(makeEnvironmentDraftRow), ); - useEffect(() => { - setRows(props.environment.map(makeEnvironmentDraftRow)); - }, [props.environment]); - const publishRows = (nextRows: ReadonlyArray) => { const published: ProviderInstanceEnvironmentVariable[] = []; for (const row of nextRows) { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx deleted file mode 100644 index 339c817bd72..00000000000 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ /dev/null @@ -1,1541 +0,0 @@ -import "../../index.css"; - -import { - type AuthAccessStreamEvent, - type AuthAccessSnapshot, - type AuthEnvironmentScope, - AuthSessionId, - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - type DesktopBridge, - type DesktopUpdateChannel, - type DesktopUpdateState, - type LocalApi, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type ServerProcessResourceHistoryResult, - type ServerProvider, - type SourceControlDiscoveryResult, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Option from "effect/Option"; -import { page } from "vite-plus/test/browser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; -import type { ReactNode } from "react"; -import { - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from "@tanstack/react-router"; - -import { __resetLocalApiForTests } from "../../localApi"; -import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; -import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; -import { useUiStateStore } from "../../uiStateStore"; -import { ConnectionsSettings } from "./ConnectionsSettings"; -import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; -import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; -import { SourceControlSettingsPanel } from "./SourceControlSettings"; - -function renderWithTestRouter(children: ReactNode) { - const rootRoute = createRootRoute({ - component: () => children, - }); - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/", - }); - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute]), - history: createMemoryHistory({ initialEntries: ["/"] }), - }); - - return render(); -} - -const authAccessHarness = vi.hoisted(() => { - type Snapshot = AuthAccessSnapshot; - let snapshot: Snapshot = { - pairingLinks: [], - clientSessions: [], - }; - let revision = 1; - const listeners = new Set<(event: AuthAccessStreamEvent) => void>(); - - const emitEvent = (event: AuthAccessStreamEvent) => { - for (const listener of listeners) { - listener(event); - } - }; - - return { - reset() { - snapshot = { - pairingLinks: [], - clientSessions: [], - }; - revision = 1; - listeners.clear(); - }, - setSnapshot(next: Snapshot) { - snapshot = next; - }, - emitSnapshot() { - emitEvent({ - version: 1 as const, - revision, - type: "snapshot" as const, - payload: snapshot, - }); - revision += 1; - }, - emitEvent, - emitPairingLinkUpserted(pairingLink: Snapshot["pairingLinks"][number]) { - emitEvent({ - version: 1, - revision, - type: "pairingLinkUpserted", - payload: pairingLink, - }); - revision += 1; - }, - emitPairingLinkRemoved(id: string) { - emitEvent({ - version: 1, - revision, - type: "pairingLinkRemoved", - payload: { id }, - }); - revision += 1; - }, - emitClientUpserted(clientSession: Snapshot["clientSessions"][number]) { - emitEvent({ - version: 1, - revision, - type: "clientUpserted", - payload: clientSession, - }); - revision += 1; - }, - emitClientRemoved(sessionId: string) { - emitEvent({ - version: 1, - revision, - type: "clientRemoved", - payload: { - sessionId: AuthSessionId.make(sessionId), - }, - }); - revision += 1; - }, - subscribe(listener: (event: AuthAccessStreamEvent) => void) { - listeners.add(listener); - listener({ - version: 1, - revision: 1, - type: "snapshot", - payload: snapshot, - }); - return () => { - listeners.delete(listener); - }; - }, - }; -}); - -const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); -const mockGetClerkToken = vi.hoisted(() => vi.fn(async () => null)); -const mockOpenClerkWaitlist = vi.hoisted(() => vi.fn()); - -vi.mock("@clerk/react", () => ({ - useAuth: () => ({ - getToken: mockGetClerkToken, - isSignedIn: false, - }), - useClerk: () => ({ - openWaitlist: mockOpenClerkWaitlist, - }), -})); - -vi.mock("../../environments/runtime", () => { - const primaryConnection = { - kind: "primary" as const, - knownEnvironment: { - id: "environment-local", - label: "Local environment", - source: "manual" as const, - environmentId: EnvironmentId.make("environment-local"), - target: { - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - }, - }, - environmentId: EnvironmentId.make("environment-local"), - client: { - server: { - subscribeAuthAccess: (listener: Parameters[0]) => - authAccessHarness.subscribe(listener), - }, - }, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }; - - return { - getEnvironmentHttpBaseUrl: () => "http://localhost:3000", - getSavedEnvironmentRecord: () => null, - getSavedEnvironmentRuntimeState: () => null, - hasSavedEnvironmentRegistryHydrated: () => true, - listSavedEnvironmentRecords: () => [], - resetSavedEnvironmentRegistryStoreForTests: () => undefined, - resetSavedEnvironmentRuntimeStoreForTests: () => undefined, - resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => - new URL(path, "http://localhost:3000").toString(), - waitForSavedEnvironmentRegistryHydration: async () => undefined, - addManagedRelayEnvironment: vi.fn(), - addSavedEnvironment: vi.fn(), - connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, - disconnectSavedEnvironment: vi.fn(), - ensureEnvironmentConnectionBootstrapped: async () => undefined, - getPrimaryEnvironmentConnection: () => primaryConnection, - readEnvironmentConnection: () => primaryConnection, - reconnectSavedEnvironment: vi.fn(), - removeSavedEnvironment: vi.fn(), - requireEnvironmentConnection: () => primaryConnection, - resetEnvironmentServiceForTests: () => undefined, - startEnvironmentConnectionService: () => undefined, - subscribeEnvironmentConnections: () => () => {}, - useSavedEnvironmentRegistryStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - useSavedEnvironmentRuntimeStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - }; -}); - -function createBaseServerConfig(): ServerConfig { - return { - environment: { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/repo/project", - keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", - keybindings: [], - issues: [], - providers: [], - availableEditors: ["cursor"], - observability: { - logsDirectoryPath: "/repo/project/.t3/logs", - localTracingEnabled: true, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpTracesEnabled: true, - otlpMetricsEnabled: false, - }, - settings: DEFAULT_SERVER_SETTINGS, - }; -} - -function createOutdatedProvider( - driver: string, - updateCommand = "npm install -g openai/codex@latest", -): ServerProvider { - return { - instanceId: ProviderInstanceId.make(driver), - driver: ProviderDriverKind.make(driver), - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-05-04T10:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - versionAdvisory: { - status: "behind_latest", - currentVersion: "1.0.0", - latestVersion: "1.1.0", - message: "Update available.", - checkedAt: "2026-05-04T10:00:00.000Z", - updateCommand, - canUpdate: true, - }, - }; -} - -function makeUtc(value: string) { - return DateTime.makeUnsafe(value); -} - -function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistoryResult { - return { - readAt: makeUtc("2036-04-07T00:00:00.000Z"), - windowMs: 15 * 60_000, - bucketMs: 60_000, - sampleIntervalMs: 5_000, - retainedSampleCount: 0, - totalCpuSecondsApprox: 0, - buckets: [], - topProcesses: [], - error: Option.none(), - }; -} - -function makePairingLink(input: { - readonly id: string; - readonly credential: string; - readonly scopes: ReadonlyArray; - readonly subject: string; - readonly label?: string; - readonly createdAt: string; - readonly expiresAt: string; -}): AuthAccessSnapshot["pairingLinks"][number] { - return { - ...input, - createdAt: makeUtc(input.createdAt), - expiresAt: makeUtc(input.expiresAt), - }; -} - -function makeClientSession(input: { - readonly sessionId: string; - readonly subject: string; - readonly scopes: ReadonlyArray; - readonly method: "browser-session-cookie"; - readonly client?: { - readonly label?: string; - readonly ipAddress?: string; - readonly userAgent?: string; - readonly deviceType?: "desktop" | "mobile" | "tablet" | "bot" | "unknown"; - readonly os?: string; - readonly browser?: string; - }; - readonly issuedAt: string; - readonly expiresAt: string; - readonly lastConnectedAt?: string | null; - readonly connected: boolean; - readonly current: boolean; -}): AuthAccessSnapshot["clientSessions"][number] { - return { - ...input, - client: { - deviceType: "unknown", - ...input.client, - }, - sessionId: AuthSessionId.make(input.sessionId), - issuedAt: makeUtc(input.issuedAt), - expiresAt: makeUtc(input.expiresAt), - lastConnectedAt: - input.lastConnectedAt === undefined || input.lastConnectedAt === null - ? null - : makeUtc(input.lastConnectedAt), - }; -} - -const createDesktopBridgeStub = (overrides?: { - readonly discoverSshHosts?: DesktopBridge["discoverSshHosts"]; - readonly serverExposureState?: Awaited>; - readonly advertisedEndpoints?: Awaited>; - readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; - readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; -}): DesktopBridge => { - const idleUpdateState: DesktopUpdateState = { - enabled: false, - status: "idle", - channel: "latest", - currentVersion: "0.0.0-test", - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, - }; - - return { - getAppBranding: vi.fn().mockReturnValue(null), - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://127.0.0.1:3773", - wsBaseUrl: "ws://127.0.0.1:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - getClientSettings: vi.fn().mockResolvedValue(null), - setClientSettings: vi.fn().mockResolvedValue(undefined), - getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]), - setSavedEnvironmentRegistry: vi.fn().mockResolvedValue(undefined), - getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), - setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), - removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), - discoverSshHosts: overrides?.discoverSshHosts ?? vi.fn().mockResolvedValue([]), - ensureSshEnvironment: vi.fn().mockImplementation(async (target) => ({ - target, - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - pairingToken: "ssh-pairing-token", - })), - disconnectSshEnvironment: vi.fn().mockResolvedValue(undefined), - fetchSshEnvironmentDescriptor: vi.fn().mockResolvedValue({ - environmentId: "environment-ssh", - label: "SSH environment", - platform: { - os: "linux", - arch: "x64", - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, - }), - bootstrapSshBearerSession: vi.fn().mockResolvedValue({ - access_token: "ssh-bearer-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "Bearer", - expires_in: 3_600, - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }), - fetchSshSessionState: vi.fn().mockResolvedValue({ - authenticated: true, - auth: { - policy: "remote-reachable", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - scopes: ["orchestration:read", "access:write"], - sessionMethod: "bearer-access-token", - expiresAt: "2026-05-01T12:00:00.000Z", - }), - issueSshWebSocketTicket: vi.fn().mockResolvedValue({ - ticket: "ssh-ws-ticket", - expiresAt: "2026-05-01T12:05:00.000Z", - }), - onSshPasswordPrompt: vi.fn(() => () => {}), - resolveSshPasswordPrompt: vi.fn().mockResolvedValue(undefined), - getServerExposureState: vi.fn().mockResolvedValue( - overrides?.serverExposureState ?? { - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - ), - setServerExposureMode: - overrides?.setServerExposureMode ?? - vi.fn().mockImplementation(async (mode) => ({ - mode, - endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, - advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - })), - setTailscaleServeEnabled: vi.fn().mockImplementation(async (input) => ({ - mode: overrides?.serverExposureState?.mode ?? "network-accessible", - endpointUrl: overrides?.serverExposureState?.endpointUrl ?? "http://192.168.1.44:3773", - advertisedHost: overrides?.serverExposureState?.advertisedHost ?? "192.168.1.44", - tailscaleServeEnabled: input.enabled, - tailscaleServePort: input.port ?? 443, - })), - getAdvertisedEndpoints: vi.fn().mockResolvedValue(overrides?.advertisedEndpoints ?? []), - pickFolder: vi.fn().mockResolvedValue(null), - confirm: vi.fn().mockResolvedValue(false), - setTheme: vi.fn().mockResolvedValue(undefined), - showContextMenu: vi.fn().mockResolvedValue(null), - openExternal: vi.fn().mockResolvedValue(true), - createCloudAuthRequest: vi.fn().mockResolvedValue("t3code-dev://auth/callback?t3_state=test"), - getCloudAuthToken: vi.fn().mockResolvedValue(null), - setCloudAuthToken: vi.fn().mockResolvedValue(true), - clearCloudAuthToken: vi.fn().mockResolvedValue(undefined), - fetchCloudAuth: vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: {}, - body: "", - }), - onCloudAuthCallback: () => () => {}, - onMenuAction: () => () => {}, - getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), - setUpdateChannel: - overrides?.setUpdateChannel ?? - vi.fn().mockImplementation(async (channel: DesktopUpdateChannel) => ({ - ...idleUpdateState, - channel, - })), - checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), - downloadUpdate: vi - .fn() - .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), - installUpdate: vi - .fn() - .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), - onUpdateState: () => () => {}, - }; -}; - -describe("GeneralSettingsPanel observability", () => { - let mounted: - | (Awaited> & { - cleanup?: () => Promise; - unmount?: () => Promise; - }) - | null = null; - - beforeEach(async () => { - resetServerStateForTests(); - await __resetLocalApiForTests(); - localStorage.clear(); - useUiStateStore.setState({ defaultAdvertisedEndpointKey: null }); - authAccessHarness.reset(); - mockConnectDesktopSshEnvironment.mockReset(); - }); - - afterEach(async () => { - if (mounted) { - const teardown = mounted.cleanup ?? mounted.unmount; - await teardown?.call(mounted).catch(() => {}); - } - mounted = null; - vi.unstubAllGlobals(); - Reflect.deleteProperty(window, "desktopBridge"); - Reflect.deleteProperty(window, "nativeApi"); - document.body.innerHTML = ""; - resetServerStateForTests(); - await __resetLocalApiForTests(); - authAccessHarness.reset(); - }); - - it("hides owner pairing tools in browser-served loopback builds without remote exposure", async () => { - Reflect.deleteProperty(window, "desktopBridge"); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions: [ - makeClientSession({ - sessionId: "session-owner", - subject: "browser-owner", - scopes: ["orchestration:read", "access:write"], - method: "browser-session-cookie", - client: { - label: "Chrome on Mac", - deviceType: "desktop", - os: "macOS", - browser: "Chrome", - ipAddress: "127.0.0.1", - }, - issuedAt: "2036-04-07T00:00:00.000Z", - expiresAt: "2036-05-07T00:00:00.000Z", - connected: true, - current: true, - }), - ], - }); - const fetchMock = vi.fn().mockImplementation(async (input) => { - const url = String(input); - if (url.endsWith("/api/auth/session")) { - return new Response( - JSON.stringify({ - authenticated: true, - auth: createBaseServerConfig().auth, - scopes: ["orchestration:read", "access:write"], - sessionMethod: "browser-session-cookie", - expiresAt: "2036-05-07T00:00:00.000Z", - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - } - - throw new Error(`Unhandled fetch GET ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - mounted = await render( - - - , - ); - - await expect - .element(page.getByRole("heading", { name: "This environment", exact: true })) - .toBeInTheDocument(); - await expect.element(page.getByLabelText("Enable network access")).toBeDisabled(); - await expect - .element( - page.getByText( - "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing.", - ), - ) - .toBeInTheDocument(); - await expect.element(page.getByText("Authorized clients")).not.toBeInTheDocument(); - await expect.element(page.getByText("Chrome on Mac")).not.toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "Remote environments", exact: true })) - .toBeInTheDocument(); - }); - - it("hides advertised endpoint rows when desktop network access is disabled", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - advertisedEndpoints: [ - { - id: "loopback", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - }, - { - id: "tailscale-ip", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.105.39.17:3773/", - wsBaseUrl: "ws://100.105.39.17:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - }, - ], - }); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions: [], - }); - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Limited to this machine.")).toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "This machine", exact: true })) - .not.toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "Tailscale IP", exact: true })) - .not.toBeInTheDocument(); - }); - - it("collapses advertised endpoints behind the network access summary", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "network-accessible", - endpointUrl: "http://192.168.86.39:3773", - advertisedHost: "192.168.86.39", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - advertisedEndpoints: [ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - }, - { - id: "desktop-lan:http://192.168.86.39:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://192.168.86.39:3773/", - wsBaseUrl: "ws://192.168.86.39:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - }, - { - id: "tailscale-ip:http://100.105.39.17:3773", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.105.39.17:3773/", - wsBaseUrl: "ws://100.105.39.17:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - }, - ], - }); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions: [], - }); - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("http://192.168.86.39:3773/")).toBeInTheDocument(); - await expect.element(page.getByRole("button", { name: "+2" })).toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "Local network", exact: true })) - .not.toBeInTheDocument(); - - await page.getByRole("button", { name: "+2" }).click(); - - await expect - .element(page.getByRole("heading", { name: "Local network", exact: true })) - .toBeInTheDocument(); - await expect.element(page.getByText("Default", { exact: true })).toBeInTheDocument(); - await page.getByRole("button", { name: "Set as default" }).first().click(); - await expect.element(page.getByText("http://127.0.0.1:3773/").first()).toBeInTheDocument(); - }); - - it("shows diagnostics inside About with a diagnostics link", async () => { - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await renderWithTestRouter( - - - , - ); - - await expect.element(page.getByText("About")).toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "Diagnostics", exact: true })) - .toBeInTheDocument(); - await expect.element(page.getByRole("link", { name: "View diagnostics" })).toBeInTheDocument(); - await expect - .element( - page.getByText( - "Local trace file. Exporting OTEL traces to http://localhost:4318/v1/traces.", - ), - ) - .toBeInTheDocument(); - }); - - it("creates and shows a pairing link when network access is enabled", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "network-accessible", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - }); - let pairingLinks: Array = []; - let clientSessions: Array = [ - makeClientSession({ - sessionId: "session-owner", - subject: "desktop-bootstrap", - scopes: ["orchestration:read", "access:write"], - method: "browser-session-cookie", - client: { - label: "This Mac", - deviceType: "desktop", - os: "macOS", - browser: "Electron", - ipAddress: "127.0.0.1", - }, - issuedAt: "2036-04-07T00:00:00.000Z", - expiresAt: "2036-05-07T00:00:00.000Z", - connected: true, - current: true, - }), - ]; - authAccessHarness.setSnapshot({ - pairingLinks, - clientSessions, - }); - vi.stubGlobal( - "fetch", - vi.fn().mockImplementation(async (input, init) => { - const url = String(input); - const method = init?.method ?? "GET"; - if (url.endsWith("/api/auth/pairing-token") && method === "POST") { - pairingLinks = [ - makePairingLink({ - id: "pairing-link-1", - credential: "pairing-token", - scopes: ["orchestration:read"], - subject: "one-time-token", - label: "Julius iPhone", - createdAt: "2036-04-07T00:00:00.000Z", - expiresAt: "2036-04-10T00:05:00.000Z", - }), - ]; - clientSessions = [ - ...clientSessions, - makeClientSession({ - sessionId: "session-client", - subject: "one-time-token", - scopes: ["orchestration:read"], - method: "browser-session-cookie", - client: { - label: "Julius iPhone", - deviceType: "mobile", - os: "iOS", - browser: "Safari", - ipAddress: "192.168.1.88", - }, - issuedAt: "2036-04-07T00:01:00.000Z", - expiresAt: "2036-05-07T00:01:00.000Z", - connected: false, - current: false, - }), - ]; - authAccessHarness.setSnapshot({ - pairingLinks, - clientSessions, - }); - return new Response( - JSON.stringify({ - id: "pairing-link-1", - credential: "pairing-token", - label: "Julius iPhone", - expiresAt: "2036-04-10T00:05:00.000Z", - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - } - - throw new Error(`Unhandled fetch ${method} ${url}`); - }), - ); - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Authorized clients")).toBeInTheDocument(); - await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); - await expect.element(page.getByText("This Mac")).toBeInTheDocument(); - await page.getByRole("button", { name: "Create link", exact: true }).click(); - await expect.element(page.getByText("Create pairing link")).toBeInTheDocument(); - await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked(); - await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).toBeChecked(); - await page.getByRole("button", { name: "Read only", exact: true }).click(); - await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked(); - await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).not.toBeChecked(); - await page.getByRole("button", { name: "Create link", exact: true }).click(); - authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!); - authAccessHarness.emitClientUpserted(clientSessions[1]!); - await expect - .element(page.getByRole("button", { name: "Pairing link scopes: show 1 scope" })) - .toBeInTheDocument(); - await expect - .element(page.getByText("Mobile · iOS · Safari · 192.168.1.88")) - .toBeInTheDocument(); - await page.getByRole("button", { name: "Client scopes: show 1 scope" }).click(); - await expect.element(page.getByText("orchestration:read", { exact: true })).toBeInTheDocument(); - await expect - .element(page.getByRole("button", { name: /^Copy pairing URL for:/ })) - .toBeInTheDocument(); - await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); - }); - - it("keeps authorized clients within a five-row fading scroll area", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "network-accessible", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - }); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions: Array.from({ length: 7 }, (_, index) => - makeClientSession({ - sessionId: `session-client-${index}`, - subject: `client-${index}`, - scopes: ["orchestration:read"], - method: "browser-session-cookie", - client: { - label: `Client ${index + 1}`, - deviceType: "desktop", - os: "macOS", - browser: "Electron", - ipAddress: `192.168.1.${index + 10}`, - }, - issuedAt: "2036-04-07T00:00:00.000Z", - expiresAt: "2036-05-07T00:00:00.000Z", - connected: index === 0, - current: index === 0, - }), - ), - }); - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Client 7")).toBeInTheDocument(); - const scrollArea = document.querySelector( - '[data-testid="authorized-clients-scroll-area"]', - ); - const viewport = scrollArea?.querySelector('[data-slot="scroll-area-viewport"]'); - - expect(scrollArea).not.toBeNull(); - expect(viewport).not.toBeNull(); - expect(scrollArea?.clientHeight).toBe(360); - expect(viewport?.scrollHeight).toBeGreaterThan(viewport?.clientHeight ?? 0); - expect(viewport?.className).toContain("mask-b-from"); - }); - - it("revokes all other paired clients from settings", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "network-accessible", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - }); - let clientSessions: Array = [ - makeClientSession({ - sessionId: "session-owner", - subject: "desktop-bootstrap", - scopes: ["orchestration:read", "access:write"], - method: "browser-session-cookie", - client: { - label: "This Mac", - deviceType: "desktop", - os: "macOS", - browser: "Electron", - }, - issuedAt: "2036-04-05T00:00:00.000Z", - expiresAt: "2036-05-05T00:00:00.000Z", - connected: true, - current: true, - }), - makeClientSession({ - sessionId: "session-client", - subject: "one-time-token", - scopes: ["orchestration:read"], - method: "browser-session-cookie", - client: { - label: "Julius iPhone", - deviceType: "mobile", - os: "iOS", - browser: "Safari", - ipAddress: "192.168.1.88", - }, - issuedAt: "2036-04-05T00:01:00.000Z", - expiresAt: "2036-05-05T00:01:00.000Z", - connected: false, - current: false, - }), - ]; - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions, - }); - - const fetchMock = vi.fn().mockImplementation(async (input, init) => { - const url = String(input); - const method = init?.method ?? "GET"; - if (url.endsWith("/api/auth/clients/revoke-others") && method === "POST") { - clientSessions = clientSessions.filter((session) => session.current); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions, - }); - authAccessHarness.emitClientRemoved("session-client"); - return new Response(JSON.stringify({ revokedCount: 1 }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - - throw new Error(`Unhandled fetch ${method} ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument(); - await page.getByRole("button", { name: "Revoke others", exact: true }).click(); - await expect.element(page.getByText("This Mac")).toBeInTheDocument(); - await expect.element(page.getByText("Julius iPhone")).not.toBeInTheDocument(); - expect(fetchMock).toHaveBeenCalled(); - }); - - it("shows a disabled network access toggle with guidance in desktop builds", async () => { - const desktopBridge = createDesktopBridgeStub(); - window.desktopBridge = desktopBridge; - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - const networkAccessToggle = page.getByLabelText("Enable network access"); - await expect.element(networkAccessToggle).not.toBeDisabled(); - await networkAccessToggle.click(); - await expect.element(page.getByText("Enable network access?")).toBeInTheDocument(); - await expect - .element(page.getByText("T3 Code will restart to expose this environment over the network.")) - .toBeInTheDocument(); - await page.getByRole("button", { name: "Restart and enable", exact: true }).click(); - await vi.waitFor(() => { - expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible"); - }); - await expect.element(page.getByText("http://192.168.1.44:3773")).toBeInTheDocument(); - }); - - it("adds desktop ssh environments from the add-environment dialog", async () => { - const discoverSshHosts = vi.fn().mockResolvedValue([ - { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - source: "ssh-config" as const, - }, - ]); - window.desktopBridge = createDesktopBridgeStub({ - discoverSshHosts, - }); - mockConnectDesktopSshEnvironment.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-devbox"), - label: "Build box", - wsBaseUrl: "ws://127.0.0.1:3774/", - httpBaseUrl: "http://127.0.0.1:3774/", - createdAt: "2036-04-07T00:00:00.000Z", - lastConnectedAt: "2036-04-07T00:00:00.000Z", - desktopSsh: { - alias: "devbox.example.com", - hostname: "devbox.example.com", - username: "julius", - port: 2222, - }, - }); - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await page.getByRole("button", { name: "Add environment", exact: true }).click(); - const addEnvironmentDialog = page.getByRole("dialog", { name: "Add Environment" }); - await expect - .element(addEnvironmentDialog.getByRole("heading", { name: "Add Environment", exact: true })) - .toBeInTheDocument(); - await addEnvironmentDialog.getByRole("button", { name: /^SSH\b/ }).click(); - await vi.waitFor(() => { - expect(discoverSshHosts).toHaveBeenCalledTimes(1); - }); - await expect - .element(page.getByRole("heading", { name: "devbox", exact: true })) - .toBeInTheDocument(); - - await addEnvironmentDialog.getByLabelText("SSH host or alias").fill("devbox.example.com"); - await addEnvironmentDialog.getByLabelText("Username").fill("julius"); - await addEnvironmentDialog.getByLabelText("Port").fill("2222"); - await addEnvironmentDialog - .getByRole("button", { name: "Add environment", exact: true }) - .first() - .click(); - - await vi.waitFor(() => { - expect(mockConnectDesktopSshEnvironment).toHaveBeenCalledWith( - { - alias: "devbox.example.com", - hostname: "devbox.example.com", - username: "julius", - port: 2222, - }, - { label: "" }, - ); - }); - }); - - it("opens the logs folder in the preferred editor", async () => { - const openInEditor = vi.fn().mockResolvedValue(undefined); - window.nativeApi = { - persistence: { - getClientSettings: vi.fn().mockResolvedValue(null), - setClientSettings: vi.fn().mockResolvedValue(undefined), - }, - shell: { - openInEditor, - }, - server: { - getProcessDiagnostics: vi.fn().mockResolvedValue({ - serverPid: 1234, - readAt: makeUtc("2036-04-07T00:00:00.000Z"), - processCount: 0, - totalRssBytes: 0, - totalCpuPercent: 0, - processes: [], - error: Option.none(), - }), - getProcessResourceHistory: vi - .fn() - .mockResolvedValue(createEmptyProcessResourceHistoryResult()), - getTraceDiagnostics: vi.fn().mockResolvedValue({ - traceFilePath: "/repo/project/.t3/traces.jsonl", - scannedFilePaths: ["/repo/project/.t3/traces.jsonl"], - readAt: makeUtc("2036-04-07T00:00:00.000Z"), - recordCount: 0, - parseErrorCount: 0, - firstSpanAt: Option.none(), - lastSpanAt: Option.none(), - failureCount: 0, - interruptionCount: 0, - slowSpanThresholdMs: 5_000, - slowSpanCount: 0, - logLevelCounts: {}, - topSpansByCount: [], - slowestSpans: [], - commonFailures: [], - latestFailures: [], - latestWarningAndErrorLogs: [], - partialFailure: Option.none(), - error: Option.none(), - }), - }, - } as unknown as LocalApi; - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - const openLogsButton = page.getByLabelText("Open logs folder"); - await openLogsButton.click(); - - expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); - }); - - it("shows an OpenCode server URL field in provider settings", async () => { - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await page.getByLabelText("Toggle OpenCode details").click(); - - // The unified provider-instance card renders field labels without a - // driver-name prefix (the driver name is already shown in the card - // header), so the labels read "Server URL" / "Server password" - // rather than the old "OpenCode server URL" / "OpenCode server password". - await expect.element(page.getByText("Server URL")).toBeInTheDocument(); - await expect.element(page.getByPlaceholder("http://127.0.0.1:4096")).toBeInTheDocument(); - await expect.element(page.getByText("Server password")).toBeInTheDocument(); - await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); - }); - - it("runs one-click provider updates from the provider card", async () => { - const updateProvider = vi.fn().mockResolvedValue({ - providers: [createOutdatedProvider("codex")], - }); - window.nativeApi = { - persistence: { - getClientSettings: vi.fn().mockResolvedValue(null), - setClientSettings: vi.fn().mockResolvedValue(undefined), - }, - server: { - updateProvider, - }, - } as unknown as LocalApi; - - setServerConfigSnapshot({ - ...createBaseServerConfig(), - providers: [createOutdatedProvider("codex")], - }); - - mounted = await render( - - - , - ); - - await page.getByRole("button", { name: "Update available — view details" }).click(); - await expect.element(page.getByRole("button", { name: "Update now" })).toBeInTheDocument(); - await page.getByRole("button", { name: "Update now" }).click(); - - expect(updateProvider).toHaveBeenCalledWith({ - provider: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - }); - }); - - it("keeps long provider update commands inside the fixed-width popover", async () => { - const longUpdateCommand = - "npm install -g @anthropic-ai/claude-code@latest --registry=https://registry.npmjs.org --cache=/tmp/t3code-provider-update-cache"; - - setServerConfigSnapshot({ - ...createBaseServerConfig(), - providers: [createOutdatedProvider("codex", longUpdateCommand)], - }); - - mounted = await render( - - - , - ); - - await page.getByRole("button", { name: "Update available — view details" }).click(); - await expect.element(page.getByText(longUpdateCommand)).toBeInTheDocument(); - - await vi.waitFor(() => { - const popup = document.querySelector('[data-slot="popover-popup"]'); - const commandCode = Array.from(document.querySelectorAll("code")).find( - (element) => element.textContent === longUpdateCommand, - ); - const scrollViewport = commandCode?.closest( - '[data-slot="scroll-area-viewport"]', - ); - - expect(popup).toBeTruthy(); - expect(commandCode).toBeTruthy(); - expect(scrollViewport).toBeTruthy(); - - const popupRect = popup!.getBoundingClientRect(); - const viewportRect = scrollViewport!.getBoundingClientRect(); - - expect(popupRect.width).toBeGreaterThan(300); - expect(popupRect.width).toBeLessThanOrEqual(337); - expect(viewportRect.right).toBeLessThanOrEqual(popupRect.right + 0.5); - expect(scrollViewport!.scrollWidth).toBeGreaterThan(scrollViewport!.clientWidth); - }); - }); -}); - -describe("SourceControlSettingsPanel discovery states", () => { - let mounted: - | (Awaited> & { - cleanup?: () => Promise; - unmount?: () => Promise; - }) - | null = null; - - beforeEach(async () => { - resetAppAtomRegistryForTests(); - await __resetLocalApiForTests(); - document.body.innerHTML = ""; - }); - - afterEach(async () => { - if (mounted) { - const teardown = mounted.cleanup ?? mounted.unmount; - await teardown?.call(mounted).catch(() => {}); - } - mounted = null; - Reflect.deleteProperty(window, "nativeApi"); - document.body.innerHTML = ""; - await __resetLocalApiForTests(); - resetAppAtomRegistryForTests(); - }); - - function setSourceControlDiscoveryStub( - discoverSourceControl: () => Promise, - ) { - window.nativeApi = { - server: { - discoverSourceControl, - }, - } as LocalApi; - } - - it("shows skeleton sections while the first source control scan is pending", async () => { - setSourceControlDiscoveryStub(() => new Promise(() => {})); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Version Control")).toBeInTheDocument(); - await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument(); - await expect - .element(page.getByRole("button", { name: "Rescan server environment" })) - .toBeDisabled(); - await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); - }); - - it("uses the shared empty state when discovery completes without tools", async () => { - setSourceControlDiscoveryStub(async () => ({ - versionControlSystems: [], - sourceControlProviders: [], - })); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument(); - await expect - .element( - page.getByText( - "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan.", - ), - ) - .toBeInTheDocument(); - await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument(); - }); - - it("keeps discovered rows instead of showing the empty state", async () => { - setSourceControlDiscoveryStub(async () => ({ - versionControlSystems: [ - { - kind: "git", - label: "Git", - executable: "git", - implemented: true, - status: "available", - version: Option.some("git version 2.50.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [], - })); - - mounted = await render( - - - , - ); - - await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); - await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); - }); - - it("shows unauthenticated API providers as available but not enabled", async () => { - setSourceControlDiscoveryStub(async () => ({ - versionControlSystems: [], - sourceControlProviders: [ - { - kind: "bitbucket", - label: "Bitbucket", - status: "available", - version: Option.none(), - installHint: - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", - detail: Option.none(), - auth: { - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", - ), - }, - }, - ], - })); - - mounted = await render( - - - , - ); - - const bitbucketSwitch = page.getByRole("switch", { name: "Bitbucket availability" }); - - await expect.element(page.getByText("Not authenticated")).toBeInTheDocument(); - await expect - .element( - page.getByText( - "Available. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", - ), - ) - .toBeInTheDocument(); - await expect.element(bitbucketSwitch).toBeDisabled(); - await expect.element(bitbucketSwitch).not.toBeChecked(); - }); - - it("shows Git fetch interval settings inside the Git details dropdown", async () => { - setSourceControlDiscoveryStub(async () => ({ - versionControlSystems: [ - { - kind: "git", - label: "Git", - executable: "git", - implemented: true, - status: "available", - version: Option.some("git version 2.50.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [], - })); - - mounted = await render( - - - , - ); - - const toggle = page.getByRole("button", { name: "Toggle Git details" }); - await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); - - await toggle.click(); - - await expect.element(toggle).toHaveAttribute("aria-expanded", "true"); - await expect - .element(page.getByLabelText("Automatic Git fetch interval in seconds")) - .toBeVisible(); - await expect - .element(page.getByText("Automatic Git fetches run every 30 seconds")) - .not.toBeInTheDocument(); - }); - - it("does not rescan on remount while the discovery atom is fresh", async () => { - let calls = 0; - setSourceControlDiscoveryStub(async () => { - calls += 1; - return { - versionControlSystems: [ - { - kind: "git", - label: "Git", - executable: "git", - implemented: true, - status: "available", - version: Option.some("git version 2.50.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [], - }; - }); - - mounted = await render( - - - , - ); - - await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); - expect(calls).toBe(1); - - const teardown = mounted.cleanup ?? mounted.unmount; - await teardown?.call(mounted).catch(() => {}); - mounted = null; - document.body.innerHTML = ""; - - mounted = await render( - - - , - ); - - await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); - expect(calls).toBe(1); - }); -}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bb0256e2983..03ddb448161 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,7 +1,7 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, type ContextMenuStyle, @@ -12,7 +12,13 @@ import { type ProviderInstanceId, type ScopedThreadRef, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Arr from "effect/Array"; @@ -32,23 +38,26 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; -import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; -import { - setDesktopUpdateStateQueryData, - useDesktopUpdateState, -} from "../../lib/desktopUpdateReactQuery"; +import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, } from "../../modelSelection"; import { + applyProviderInstanceSettings, deriveProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; -import { useShallow } from "zustand/react/shallow"; -import { selectProjectsAcrossEnvironments, useStore } from "../../store"; +import { + primaryServerObservabilityAtom, + primaryServerProvidersAtom, + serverEnvironment, +} from "../../state/server"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -79,7 +88,7 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { ProjectFavicon } from "../ProjectFavicon"; -import { useServerObservability, useServerProviders } from "../../rpc/serverState"; +import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ { @@ -162,11 +171,9 @@ function AboutVersionTitle() { } function AboutVersionSection() { - const queryClient = useQueryClient(); - const updateStateQuery = useDesktopUpdateState(); + const updateState = useDesktopUpdateState(); const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); - const updateState = updateStateQuery.data ?? null; const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; @@ -185,9 +192,6 @@ function AboutVersionSection() { setIsChangingUpdateChannel(true); void bridge .setUpdateChannel(channel) - .then((state) => { - setDesktopUpdateStateQueryData(queryClient, state); - }) .catch((error: unknown) => { toastManager.add( stackedThreadToast({ @@ -201,7 +205,7 @@ function AboutVersionSection() { setIsChangingUpdateChannel(false); }); }, - [queryClient, selectedUpdateChannel], + [selectedUpdateChannel], ); const handleButtonClick = useCallback(() => { @@ -211,20 +215,15 @@ function AboutVersionSection() { const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; if (action === "download") { - void bridge - .downloadUpdate() - .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); - }) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: error instanceof Error ? error.message : "Download failed.", - }), - ); - }); + void bridge.downloadUpdate().catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: error instanceof Error ? error.message : "Download failed.", + }), + ); + }); return; } @@ -235,20 +234,15 @@ function AboutVersionSection() { ), ); if (!confirmed) return; - void bridge - .installUpdate() - .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); - }) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "Install failed.", - }), - ); - }); + void bridge.installUpdate().catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "Install failed.", + }), + ); + }); return; } @@ -256,7 +250,6 @@ function AboutVersionSection() { void bridge .checkForUpdate() .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); if (!result.checked) { toastManager.add( stackedThreadToast({ @@ -277,7 +270,7 @@ function AboutVersionSection() { }), ); }); - }, [queryClient, updateState]); + }, [updateState]); const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; @@ -388,8 +381,8 @@ function AboutVersionSection() { export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); - const settings = useSettings(); - const { updateSettings } = useUpdateSettings(); + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); const isGitWritingModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, @@ -408,9 +401,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), - ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap - ? ["Diff line wrapping"] - : []), + ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -427,6 +418,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.newWorktreesStartFromOrigin !== + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin + ? ["New worktrees start from origin"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -446,12 +441,13 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.contextMenuStyle, settings.defaultThreadEnvMode, + settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, - settings.diffWordWrap, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.sidebarThreadPreviewCount, settings.timestampFormat, + settings.wordWrap, theme, ], ); @@ -470,13 +466,14 @@ export function useSettingsRestore(onRestored?: () => void) { updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, contextMenuStyle: DEFAULT_UNIFIED_SETTINGS.contextMenuStyle, - diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, + wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -493,10 +490,10 @@ export function useSettingsRestore(onRestored?: () => void) { export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); - const settings = useSettings(); - const { updateSettings } = useUpdateSettings(); - const observability = useServerObservability(); - const serverProviders = useServerProviders(); + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const observability = useAtomValue(primaryServerObservabilityAtom); + const serverProviders = useAtomValue(primaryServerProvidersAtom); const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -510,7 +507,7 @@ export function GeneralSettingsPanel() { const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const gitModelInstanceEntries = sortProviderInstanceEntries( - deriveProviderInstanceEntries(serverProviders), + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), ); const textGenInstanceEntry = gitModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, @@ -649,15 +646,15 @@ export function GeneralSettingsPanel() { /> updateSettings({ - diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, + wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, }) } /> @@ -665,9 +662,9 @@ export function GeneralSettingsPanel() { } control={ updateSettings({ diffWordWrap: Boolean(checked) })} - aria-label="Wrap diff lines by default" + checked={settings.wordWrap} + onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })} + aria-label="Wrap code, tables, diffs, and file previews by default" /> } /> @@ -725,6 +722,33 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, + }) + } + /> + ) : null + } + control={ + + updateSettings({ enableProviderUpdateChecks: Boolean(checked) }) + } + aria-label="Check provider versions" + /> + } + /> + updateSettings({ defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, }) } /> @@ -792,6 +820,37 @@ export function GeneralSettingsPanel() { } /> + {settings.defaultThreadEnvMode === "worktree" ? ( + + updateSettings({ + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + }) + } + /> + ) : null + } + control={ + + updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) + } + aria-label="Start new worktrees from origin by default" + /> + } + /> + ) : null} + { - console.warn("Failed to refresh providers", error); - }) - .finally(() => { - refreshingRef.current = false; - setIsRefreshingProviders(false); + if (!primaryEnvironment) { + refreshingRef.current = false; + setIsRefreshingProviders(false); + return; + } + void (async () => { + const result = await refreshServerProviders({ + environmentId: primaryEnvironment.environmentId, + input: {}, }); - }, []); + refreshingRef.current = false; + setIsRefreshingProviders(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to refresh providers", { + operation: "refresh-providers", + environmentId: primaryEnvironment.environmentId, + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); + } + })(); + }, [primaryEnvironment, refreshServerProviders]); - const runProviderUpdate = useCallback(async (candidate: ProviderUpdateCandidate) => { - let started = false; - setUpdatingProviderDrivers((previous) => { - if (previous.has(candidate.driver)) { - return previous; + const runProviderUpdate = useCallback( + async (candidate: ProviderUpdateCandidate) => { + if (!primaryEnvironment) return; + let started = false; + setUpdatingProviderDrivers((previous) => { + if (previous.has(candidate.driver)) { + return previous; + } + started = true; + const next = new Set(previous); + next.add(candidate.driver); + return next; + }); + if (!started) { + return; } - started = true; - const next = new Set(previous); - next.add(candidate.driver); - return next; - }); - if (!started) { - return; - } - try { - await ensureLocalApi().server.updateProvider({ - provider: candidate.driver, - instanceId: candidate.instanceId, + const result = await updateProvider({ + environmentId: primaryEnvironment.environmentId, + input: { + provider: candidate.driver, + instanceId: candidate.instanceId, + }, }); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, - description: - error instanceof Error - ? error.message - : "The provider update command could not be started.", - }), - ); - } finally { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } setUpdatingProviderDrivers((previous) => { if (!previous.has(candidate.driver)) { return previous; @@ -1063,8 +1145,9 @@ export function ProviderSettingsPanel() { next.delete(candidate.driver); return next; }); - } - }, []); + }, + [primaryEnvironment, updateProvider], + ); interface InstanceRow { readonly instanceId: ProviderInstanceId; @@ -1384,16 +1467,15 @@ export function ProviderSettingsPanel() { })} - + {isAddInstanceDialogOpen ? ( + + ) : null} ); } export function ArchivedThreadsPanel() { - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], @@ -1469,10 +1551,11 @@ export function ArchivedThreadsPanel() { ); if (clicked === "unarchive") { - try { - await unarchiveThread(threadRef); + const result = await unarchiveThread(threadRef); + if (result._tag === "Success") { refreshArchivedThreads(); - } catch (error) { + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1485,8 +1568,19 @@ export function ArchivedThreadsPanel() { } if (clicked === "delete") { - await confirmAndDeleteThread(threadRef); - refreshArchivedThreads(); + const result = await confirmAndDeleteThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } } }, [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], @@ -1530,13 +1624,28 @@ export function ArchivedThreadsPanel() { key={thread.id} onContextMenu={(event) => { event.preventDefault(); - void handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ); + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + { + x: event.clientX, + y: event.clientY, + }, + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived thread action failed", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); }} title={thread.title} description={ @@ -1552,10 +1661,17 @@ export function ArchivedThreadsPanel() { variant="outline" size="sm" className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" - onClick={() => - void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)) - .then(() => refreshArchivedThreads()) - .catch((error) => { + onClick={() => { + void (async () => { + const result = await unarchiveThread( + scopeThreadRef(thread.environmentId, thread.id), + ); + if (result._tag === "Success") { + refreshArchivedThreads(); + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1564,8 +1680,9 @@ export function ArchivedThreadsPanel() { error instanceof Error ? error.message : "An error occurred.", }), ); - }) - } + } + })(); + }} > Unarchive diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 00656f9fd2d..b6d23de4f79 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -12,12 +12,11 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; -import { - refreshSourceControlDiscovery, - useSourceControlDiscovery, -} from "../../lib/sourceControlDiscoveryState"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { sourceControlEnvironment } from "../../state/sourceControl"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -292,8 +291,10 @@ function DiscoveryItemRow({ } function GitFetchIntervalSettings() { - const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval); - const { updateSettings } = useUpdateSettings(); + const automaticGitFetchInterval = usePrimarySettings( + (settings) => settings.automaticGitFetchInterval, + ); + const updateSettings = useUpdatePrimarySettings(); const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, @@ -439,13 +440,21 @@ function EmptySourceControlDiscovery({ } export function SourceControlSettingsPanel() { - const discovery = useSourceControlDiscovery(); + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const discovery = useEnvironmentQuery( + environmentId === null + ? null + : sourceControlEnvironment.discovery({ + environmentId, + input: {}, + }), + ); const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; const hasDiscoveryItems = result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; const isInitialScanPending = discovery.isPending && discovery.data === null; const handleScan = () => { - void refreshSourceControlDiscovery(); + discovery.refresh(); }; const scanButton = ( diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index d5b1b9bddd4..b28b967eefa 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -1,9 +1,10 @@ import { useNavigate } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import type { ServerProvider } from "@t3tools/contracts"; import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useState, type CSSProperties } from "react"; -import { useServerProviders } from "../../rpc/serverState"; +import { primaryServerProvidersAtom } from "../../state/server"; import { getProviderUpdateSidebarPillView, type ProviderUpdateSidebarPillView, @@ -39,7 +40,7 @@ function latestProviderCheckedAt( export function SidebarProviderUpdatePill() { const navigate = useNavigate(); - const providers = useServerProviders(); + const providers = useAtomValue(primaryServerProvidersAtom); const [dismissedKeys, setDismissedKeys] = useState>(() => new Set()); const [renderedView, setRenderedView] = useState(null); const [pendingView, setPendingView] = useState(null); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index d7e5b74d42d..c3ac56d1092 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,11 +1,7 @@ import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { isElectron } from "../../env"; -import { - setDesktopUpdateStateQueryData, - useDesktopUpdateState, -} from "../../lib/desktopUpdateReactQuery"; +import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { getArm64IntelBuildWarningDescription, @@ -22,8 +18,7 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function SidebarUpdatePill() { - const queryClient = useQueryClient(); - const state = useDesktopUpdateState().data ?? null; + const state = useDesktopUpdateState(); const [dismissed, setDismissed] = useState(false); const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; @@ -44,7 +39,6 @@ export function SidebarUpdatePill() { void bridge .downloadUpdate() .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); if (result.completed) { toastManager.add({ type: "success", @@ -81,7 +75,6 @@ export function SidebarUpdatePill() { void bridge .installUpdate() .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; @@ -103,7 +96,7 @@ export function SidebarUpdatePill() { ); }); } - }, [action, disabled, queryClient, state]); + }, [action, disabled, state]); if (!visible && !showArm64Warning) return null; diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index 1332bbe2517..904f8664772 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -6,7 +6,9 @@ import { SidebarMenuButton, SidebarMenuSubButton, SidebarProvider, + SidebarTrigger, } from "./sidebar"; +import { resolveSidebarState } from "./sidebarState"; function renderSidebarButton(className?: string) { return renderToStaticMarkup( @@ -17,6 +19,37 @@ function renderSidebarButton(className?: string) { } describe("sidebar interactive cursors", () => { + it("uses mobile sheet visibility for the shared responsive state", () => { + expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe( + "collapsed", + ); + expect(resolveSidebarState({ isMobile: true, open: false, openMobile: true })).toBe("expanded"); + expect(resolveSidebarState({ isMobile: false, open: true, openMobile: false })).toBe( + "expanded", + ); + }); + + it("exposes collapsed state for shared titlebar inset styling", () => { + const html = renderToStaticMarkup( + +
    + , + ); + + expect(html).toContain('data-sidebar-state="collapsed"'); + }); + + it("keeps the sidebar trigger interactive inside Electron drag regions", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("[-webkit-app-region:no-drag]"); + expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!"); + }); + it("uses a pointer cursor for menu buttons by default", () => { const html = renderSidebarButton(); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 718fc22b3fe..097568f77f0 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; @@ -29,7 +30,7 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; type SidebarContextProps = { - state: "expanded" | "collapsed"; + state: ResponsiveSidebarState; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; @@ -85,6 +86,11 @@ function useSidebar() { return context; } +function useSidebarVisibility() { + const { isMobile, open, openMobile } = useSidebar(); + return isMobile ? openMobile : open; +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -132,7 +138,7 @@ function SidebarProvider({ // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; + const state = resolveSidebarState({ isMobile, open, openMobile }); const contextValue = React.useMemo( () => ({ @@ -154,6 +160,7 @@ function SidebarProvider({ "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className, )} + data-sidebar-state={state} data-slot="sidebar-wrapper" style={ { @@ -310,13 +317,18 @@ function Sidebar({ } function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { - const { toggleSidebar, openMobile } = useSidebar(); + const { toggleSidebar } = useSidebar(); + const isOpen = useSidebarVisibility(); return ( ); @@ -1004,4 +1016,5 @@ export { SidebarSeparator, SidebarTrigger, useSidebar, + useSidebarVisibility, }; diff --git a/apps/web/src/components/ui/sidebarState.ts b/apps/web/src/components/ui/sidebarState.ts new file mode 100644 index 00000000000..fcdfed10521 --- /dev/null +++ b/apps/web/src/components/ui/sidebarState.ts @@ -0,0 +1,9 @@ +export type ResponsiveSidebarState = "expanded" | "collapsed"; + +export function resolveSidebarState(input: { + isMobile: boolean; + open: boolean; + openMobile: boolean; +}): ResponsiveSidebarState { + return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed"; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 2c2a554871a..d48cda453b8 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -117,7 +117,7 @@ function handleToastDismissClick( } function CopyErrorButton({ text }: { text: string }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" }); const label = isCopied ? "Copied error" : "Copy error"; return ( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 1da5dadf74d..bc1b7107306 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -3,7 +3,7 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import { defaultInstanceIdForDriver, @@ -59,6 +59,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, + clearComposerDraftsEnvironment, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, @@ -696,6 +697,40 @@ describe("composerDraftStore project draft thread mapping", () => { resetComposerDraftStore(); }); + it("clears composer data for one environment without touching another", () => { + const store = useComposerDraftStore.getState(); + const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, otherThreadId); + const originalRevokeObjectUrl = URL.revokeObjectURL; + const revokeSpy = vi.fn<(url: string) => void>(); + URL.revokeObjectURL = revokeSpy; + + try { + store.setProjectDraftThreadId(projectRef, localDraftId, { threadId }); + store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { + threadId: otherThreadId, + }); + store.setPrompt(localDraftId, "local draft"); + store.setPrompt(remoteDraftId, "remote draft"); + store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" })); + store.setPrompt(localThreadRef, "local thread draft"); + store.setPrompt(remoteThreadRef, "remote thread draft"); + + clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID); + + const next = useComposerDraftStore.getState(); + expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull(); + expect(next.getComposerDraft(localDraftId)).toBeNull(); + expect(next.getComposerDraft(remoteDraftId)?.prompt).toBe("remote thread draft"); + expect(next.getComposerDraft(localThreadRef)).toBeNull(); + expect(next.getComposerDraft(remoteThreadRef)?.prompt).toBe("remote thread draft"); + expect(revokeSpy).toHaveBeenCalledWith("blob:local-draft"); + } finally { + URL.revokeObjectURL = originalRevokeObjectUrl; + } + }); + it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); @@ -965,6 +1000,18 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); + it("finalizes a matching materialized draft even when promotion was not pre-marked", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + + finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); + }); + it("updates branch context on an existing draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { @@ -988,6 +1035,21 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("stores the start-from-origin choice with the draft thread", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + envMode: "worktree", + startFromOrigin: true, + }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(true); + + store.setDraftThreadContext(draftId, { startFromOrigin: false }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(false); + }); + it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aaaa94c1dd2..fdb8bfe7b18 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -24,9 +24,10 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; +import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; @@ -214,6 +215,7 @@ const PersistedDraftThreadState = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, + startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -292,6 +294,7 @@ export interface DraftSessionState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + startFromOrigin: boolean; promotedTo?: ScopedThreadRef | null; } @@ -353,6 +356,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -367,6 +371,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -380,6 +385,7 @@ interface ComposerDraftStoreState { projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1313,6 +1319,7 @@ function createDraftThreadState( worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1333,6 +1340,12 @@ function createDraftThreadState( ? null : (existingThread?.branch ?? null) : (options.branch ?? null); + const nextStartFromOrigin = + options?.startFromOrigin === undefined + ? projectChanged + ? false + : (existingThread?.startFromOrigin ?? false) + : options.startFromOrigin; return { threadId, environmentId: projectRef.environmentId, @@ -1351,6 +1364,7 @@ function createDraftThreadState( : projectChanged ? "local" : (existingThread?.envMode ?? "local")), + startFromOrigin: nextStartFromOrigin, promotedTo: null, }; } @@ -1382,6 +1396,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.branch === right.branch && left.worktreePath === right.worktreePath && left.envMode === right.envMode && + left.startFromOrigin === right.startFromOrigin && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1476,6 +1491,7 @@ function normalizePersistedDraftThreads( const createdAt = candidateDraftThread.createdAt; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; + const startFromOrigin = candidateDraftThread.startFromOrigin === true; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1523,6 +1539,7 @@ function normalizePersistedDraftThreads( branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), + startFromOrigin, promotedTo, }; } @@ -1568,6 +1585,7 @@ function normalizePersistedDraftThreads( branch: null, worktreePath: null, envMode: "local", + startFromOrigin: false, promotedTo: null, }; } else if ( @@ -2138,6 +2156,7 @@ function toHydratedDraftThreadState( branch: persistedDraftThread.branch, worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, + startFromOrigin: persistedDraftThread.startFromOrigin, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2323,6 +2342,12 @@ const composerDraftStore = create()( ? null : existing.branch : (options.branch ?? null); + const nextStartFromOrigin = + options.startFromOrigin === undefined + ? projectChanged + ? false + : existing.startFromOrigin + : options.startFromOrigin; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2343,6 +2368,7 @@ const composerDraftStore = create()( : projectChanged ? "local" : (existing.envMode ?? "local")), + startFromOrigin: nextStartFromOrigin, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2355,6 +2381,7 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && + nextDraftThread.startFromOrigin === existing.startFromOrigin && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; @@ -3347,6 +3374,60 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; +export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { + useComposerDraftStore.setState((state) => { + const removedThreadKeys = new Set(); + + for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if (draftThread.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + for (const threadKey of Object.keys(state.draftsByThreadKey)) { + if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + for (const [logicalProjectKey, threadKey] of Object.entries( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + )) { + if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + + const nextLogicalMappings = Object.fromEntries( + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( + ([logicalProjectKey, threadKey]) => + parseScopedProjectKey(logicalProjectKey)?.environmentId !== environmentId && + !removedThreadKeys.has(threadKey), + ), + ) as Record; + const nextDraftThreads = Object.fromEntries( + Object.entries(state.draftThreadsByThreadKey).filter( + ([threadKey, draftThread]) => + draftThread.environmentId !== environmentId && !removedThreadKeys.has(threadKey), + ), + ) as Record; + const nextDrafts = Object.fromEntries( + Object.entries(state.draftsByThreadKey).filter(([threadKey, draft]) => { + if (!removedThreadKeys.has(threadKey)) { + return true; + } + revokeDraftThreadPreviewUrls(draft); + return false; + }), + ) as Record; + + return { + draftsByThreadKey: nextDrafts, + draftThreadsByThreadKey: nextDraftThreads, + logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, + }; + }); + composerDebouncedStorage.flush(); +} + export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState { return useComposerDraftStore((state) => { return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT; @@ -3459,12 +3540,16 @@ export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable + JSON.stringify(input), + }, + execute: (input: { + readonly pairingUrl?: string; + readonly host?: string; + readonly pairingCode?: string; + }) => + ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.registerPairing(input))), +}); + +export const connectSshEnvironment = createRuntimeCommand(connectionAtomRuntime, { + label: "web:connection:connect-ssh", + scheduler: onboardingScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly target: DesktopSshEnvironmentTarget }) => JSON.stringify(input.target), + }, + execute: (input: { readonly target: DesktopSshEnvironmentTarget; readonly label?: string }) => + ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.registerSsh(input))), +}); diff --git a/apps/web/src/connection/platform.test.ts b/apps/web/src/connection/platform.test.ts new file mode 100644 index 00000000000..2b428e26698 --- /dev/null +++ b/apps/web/src/connection/platform.test.ts @@ -0,0 +1,88 @@ +import { + AuthStandardClientScopes, + EnvironmentId, + type DesktopBridge, + type DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { provisionDesktopSshEnvironment } from "./platform.ts"; + +const TARGET: DesktopSshEnvironmentTarget = { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, +}; + +function makeBridge( + calls: string[], + options?: { readonly failDescriptor?: boolean }, +): DesktopBridge { + return { + ensureSshEnvironment: async (target: DesktopSshEnvironmentTarget) => { + calls.push("ensure"); + return { + target, + httpBaseUrl: "http://127.0.0.1:3201/", + wsBaseUrl: "ws://127.0.0.1:3201/", + pairingToken: "pairing-token", + }; + }, + fetchSshEnvironmentDescriptor: async () => { + calls.push("descriptor"); + if (options?.failDescriptor === true) { + throw new Error("descriptor unavailable"); + } + return { + environmentId: EnvironmentId.make("environment-ssh"), + label: "SSH environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }; + }, + bootstrapSshBearerSession: async () => { + calls.push("token"); + return { + access_token: "bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3_600, + scope: AuthStandardClientScopes.join(" "), + }; + }, + } as unknown as DesktopBridge; +} + +describe("desktop SSH pairing", () => { + it.effect("fetches the descriptor before consuming the one-time credential", () => + Effect.gen(function* () { + const calls: string[] = []; + + const provisioned = yield* provisionDesktopSshEnvironment(makeBridge(calls), TARGET); + + expect(provisioned.environmentId).toBe(EnvironmentId.make("environment-ssh")); + expect(calls).toEqual(["ensure", "descriptor", "token"]); + }), + ); + + it.effect("does not consume the credential when descriptor discovery fails", () => + Effect.gen(function* () { + const calls: string[] = []; + + yield* provisionDesktopSshEnvironment( + makeBridge(calls, { failDescriptor: true }), + TARGET, + ).pipe(Effect.flip); + + expect(calls).toEqual(["ensure", "descriptor"]); + }), + ); +}); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts new file mode 100644 index 00000000000..1b7cba5cbfd --- /dev/null +++ b/apps/web/src/connection/platform.ts @@ -0,0 +1,360 @@ +import { + ClientPresentation, + CloudSession, + EnvironmentOwnedDataCleanup, + PlatformConnectionSource, + PrimaryEnvironmentAuth, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "@t3tools/client-runtime/platform"; +import { + ConnectionBlockedError, + ConnectionTransientError, + Connectivity, + mapRemoteEnvironmentError, + PrimaryConnectionRegistration, + PrimaryConnectionTarget, + Wakeups, +} from "@t3tools/client-runtime/connection"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; +import { + AuthStandardClientScopes, + type DesktopBridge, + type DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; + +import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; +import { readPrimaryEnvironmentTarget } from "../environments/primary/target"; +import { clearComposerDraftsEnvironment } from "../composerDraftStore"; +import { isHostedStaticApp } from "../hostedPairing"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; +import { connectionStorageLayer } from "./storage"; + +let nextObservedRpcRequestId = 0; + +function currentNetworkStatus(): "unknown" | "offline" | "online" { + if (typeof navigator === "undefined") { + return "unknown"; + } + return navigator.onLine ? "online" : "offline"; +} + +const connectivityLayer = Connectivity.layer({ + status: Effect.sync(currentNetworkStatus), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const online = () => Queue.offerUnsafe(queue, "online"); + const offline = () => Queue.offerUnsafe(queue, "offline"); + window.addEventListener("online", online); + window.addEventListener("offline", offline); + return { online, offline }; + }), + ({ online, offline }) => + Effect.sync(() => { + window.removeEventListener("online", online); + window.removeEventListener("offline", offline); + }), + ).pipe(Effect.asVoid), + ), +}); + +const wakeupsLayer = Wakeups.layer({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const listener = () => { + if (document.visibilityState === "visible") { + Queue.offerUnsafe(queue, "application-active"); + } + }; + document.addEventListener("visibilitychange", listener); + return listener; + }), + (listener) => + Effect.sync(() => { + document.removeEventListener("visibilitychange", listener); + }), + ).pipe(Effect.asVoid), + ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), + ), + ), +}); + +function clientMetadata() { + const desktop = window.desktopBridge !== undefined; + const platform = navigator.platform.trim(); + return { + label: desktop ? "T3 Code Desktop" : "T3 Code Web", + deviceType: "desktop" as const, + ...(platform === "" ? {} : { os: platform }), + }; +} + +function sshPreparationError(cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + if (message.toLowerCase().includes("cancel")) { + return new ConnectionBlockedError({ + reason: "authentication", + detail: message, + }); + } + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not prepare the SSH environment: ${message}`, + }); +} + +export const provisionDesktopSshEnvironment = Effect.fn( + "web.connectionPlatform.ssh.provisionDesktop", +)(function* (bridge: DesktopBridge, target: DesktopSshEnvironmentTarget) { + const bootstrap = yield* Effect.tryPromise({ + try: () => + bridge.ensureSshEnvironment(target, { + issuePairingToken: true, + }), + catch: sshPreparationError, + }); + const pairingToken = bootstrap.pairingToken; + if (pairingToken === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The SSH environment did not issue a pairing credential.", + }); + } + const descriptor = yield* Effect.tryPromise({ + try: () => bridge.fetchSshEnvironmentDescriptor(bootstrap.httpBaseUrl), + catch: sshPreparationError, + }); + const access = yield* Effect.tryPromise({ + try: () => bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, pairingToken), + catch: sshPreparationError, + }); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + bootstrap, + bearerToken: access.access_token, + }; +}); + +const capabilitiesLayer = Layer.effectContext( + Effect.sync(() => { + const presentation = ClientPresentation.of({ + metadata: clientMetadata(), + scopes: AuthStandardClientScopes, + }); + const cloudSession = CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "Sign in to T3 Cloud to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + detail: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The T3 Cloud session is unavailable.", + }); + } + return token; + }), + }); + const identity = RelayDeviceIdentity.of({ + deviceId: Effect.succeed(Option.none()), + }); + const primaryAuth = PrimaryEnvironmentAuth.of({ + bearerToken: Effect.tryPromise({ + try: readDesktopPrimaryBearerToken, + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not load the desktop primary credential: ${String(cause)}`, + }), + }).pipe(Effect.map(Option.fromNullishOr)), + }); + const ssh = SshEnvironmentGateway.of({ + provision: Effect.fn("web.connectionPlatform.ssh.provision")(function* (target) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return yield* new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }); + } + return yield* provisionDesktopSshEnvironment(bridge, target); + }), + prepare: Effect.fn("web.connectionPlatform.ssh.prepare")(function* (input) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return yield* new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }); + } + const bootstrap = yield* Effect.tryPromise({ + try: () => + bridge.ensureSshEnvironment(input.target, { + issuePairingToken: true, + }), + catch: sshPreparationError, + }); + if (bootstrap.pairingToken === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The SSH environment did not issue a pairing credential.", + }); + } + const access = yield* Effect.tryPromise({ + try: () => + bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!), + catch: sshPreparationError, + }); + return { + bootstrap, + bearerToken: access.access_token, + }; + }), + disconnect: Effect.fn("web.connectionPlatform.ssh.disconnect")(function* (target) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return; + } + yield* Effect.tryPromise({ + try: () => bridge.disconnectSshEnvironment(target), + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not disconnect the SSH environment: ${String(cause)}`, + }), + }); + }), + }); + + return Context.make(CloudSession, cloudSession).pipe( + Context.add(PrimaryEnvironmentAuth, primaryAuth), + Context.add(RelayDeviceIdentity, identity), + Context.add(ClientPresentation, presentation), + Context.add(SshEnvironmentGateway, ssh), + ); + }), +); + +const loadPrimaryConnectionRegistration = Effect.fn( + "web.connectionPlatform.loadPrimaryConnectionRegistration", +)(function* () { + const resolved = readPrimaryEnvironmentTarget(); + if (resolved === null) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: "Unable to resolve the primary environment endpoint.", + }); + } + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe(Effect.provide(primaryEnvironmentHttpLayer), Effect.mapError(mapRemoteEnvironmentError)); + return new PrimaryConnectionRegistration({ + target: new PrimaryConnectionTarget({ + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: resolved.target.httpBaseUrl, + wsBaseUrl: resolved.target.wsBaseUrl, + }), + }); +}); + +const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe( + Schedule.either(Schedule.spaced("16 seconds")), +); + +const platformConnectionSourceLayer = Layer.sync(PlatformConnectionSource, () => { + if (isHostedStaticApp()) { + return PlatformConnectionSource.of({ + registrations: Stream.empty, + }); + } + return PlatformConnectionSource.of({ + registrations: Stream.fromEffect(loadPrimaryConnectionRegistration()).pipe( + Stream.tapError((error) => + Effect.logWarning("Could not discover the primary environment.", { + error, + }), + ), + Stream.retry(primaryRegistrationRetrySchedule), + Stream.catchCause(() => Stream.empty), + ), + }); +}); + +const environmentOwnedDataCleanupLayer = Layer.succeed( + EnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Effect.sync(() => { + clearComposerDraftsEnvironment(environmentId); + }), + }), +); + +const rpcRequestObserverLayer = Layer.succeed( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: ({ environmentId, method }) => + Effect.sync(() => { + nextObservedRpcRequestId += 1; + const requestId = `${environmentId}:${nextObservedRpcRequestId}`; + trackRpcRequestSent(requestId, `${method} · ${environmentId}`); + return Effect.sync(() => { + acknowledgeRpcRequest(requestId); + }); + }), + }), +); + +type ConnectionPlatformLayerSource = + | typeof connectionStorageLayer + | typeof connectivityLayer + | typeof wakeupsLayer + | typeof capabilitiesLayer + | typeof platformConnectionSourceLayer + | typeof environmentOwnedDataCleanupLayer + | typeof rpcRequestObserverLayer; + +export const connectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error, + Layer.Services +> = Layer.mergeAll( + connectionStorageLayer, + connectivityLayer, + wakeupsLayer, + capabilitiesLayer, + platformConnectionSourceLayer, + environmentOwnedDataCleanupLayer, + rpcRequestObserverLayer, +); diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts new file mode 100644 index 00000000000..3698a0a5fc7 --- /dev/null +++ b/apps/web/src/connection/runtime.ts @@ -0,0 +1,24 @@ +import { Connection } from "@t3tools/client-runtime/connection"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import { runtimeContextLayer } from "../lib/runtime"; +import { connectionPlatformLayer } from "./platform"; + +const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( + Layer.provide(runtimeContextLayer), +); + +type ConnectionLayerSource = + | typeof Connection.layer + | typeof runtimeContextLayer + | typeof connectionPlatformLayer; + +const connectionLayer = Connection.layer.pipe( + Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +); + +export const connectionAtomRuntime: Atom.AtomRuntime< + Layer.Success, + Layer.Error +> = Atom.runtime(connectionLayer); diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts new file mode 100644 index 00000000000..6d503387bb6 --- /dev/null +++ b/apps/web/src/connection/storage.test.ts @@ -0,0 +1,77 @@ +import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; +import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { afterEach, vi } from "vite-plus/test"; + +import { makeCatalogBackend, makeCatalogStore } from "./storage"; + +const emptyCatalog = { + schemaVersion: 1, + targets: [], + profiles: [], + credentials: [], + remoteDpopTokens: [], +} as const; +const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("makeCatalogStore", () => { + it.effect("quarantines malformed catalogs and starts from an empty document", () => + Effect.gen(function* () { + const writes: string[] = []; + const quarantined: string[] = []; + const store = yield* makeCatalogStore({ + read: Effect.succeed("{not-json"), + write: (raw) => Effect.sync(() => writes.push(raw)), + quarantine: (raw) => Effect.sync(() => quarantined.push(raw)), + }); + + expect(yield* store.read).toEqual(emptyCatalog); + expect(quarantined).toEqual(["{not-json"]); + expect(writes).toHaveLength(1); + expect(decodeCatalog(writes[0]!)).toEqual(emptyCatalog); + }), + ); + + it.effect("does not hide catalog read failures", () => + Effect.gen(function* () { + const failure = new ConnectionTransientError({ + reason: "remote-unavailable", + detail: "permission denied", + }); + const store = yield* makeCatalogStore({ + read: Effect.fail(failure), + write: () => Effect.void, + }); + + expect(yield* Effect.flip(store.read)).toBe(failure); + }), + ); +}); + +describe("makeCatalogBackend", () => { + it.effect("fails writes when desktop secure storage declines the catalog", () => + Effect.gen(function* () { + const setConnectionCatalog = vi.fn().mockResolvedValue(false); + vi.stubGlobal("window", { + desktopBridge: { + getConnectionCatalog: vi.fn().mockResolvedValue(null), + setConnectionCatalog, + }, + }); + const backend = makeCatalogBackend({} as IDBDatabase); + + const error = yield* backend.write("{}").pipe(Effect.flip); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error.message).toContain("Desktop secure storage is unavailable"); + expect(setConnectionCatalog).toHaveBeenCalledWith("{}"); + }), + ); +}); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts new file mode 100644 index 00000000000..d118a428ed7 --- /dev/null +++ b/apps/web/src/connection/storage.ts @@ -0,0 +1,536 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, + ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EMPTY_CONNECTION_CATALOG_DOCUMENT, + EnvironmentCacheStore, + registerConnectionInCatalog, + removeCatalogValue, + removeConnectionFromCatalog, + replaceCatalogValue, +} from "@t3tools/client-runtime/platform"; +import { TokenStore } from "@t3tools/client-runtime/authorization"; +import { + ConnectionTransientError, + CredentialStore, + ProfileStore, +} from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThread, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +const DATABASE_NAME = "t3code:connection-runtime"; +const DATABASE_VERSION = 2; +const CATALOG_STORE_NAME = "catalog"; +const SHELL_STORE_NAME = "shell"; +const THREAD_STORE_NAME = "thread"; +const CATALOG_KEY = "document"; +const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; + +const StoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, +}); +const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); +const StoredThreadSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(1), + environmentId: EnvironmentId, + threadId: ThreadId, + thread: OrchestrationThread, +}); +const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); +const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); +const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); +const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); +const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); +const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); +const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); +const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +function persistenceError( + operation: + | "list-targets" + | "register-connection" + | "remove-connection" + | "load-shell" + | "save-shell" + | "load-thread" + | "save-thread" + | "remove-thread" + | "clear-environment", + cause: unknown, +) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* () { + return yield* Effect.callback((resume) => { + if (typeof indexedDB === "undefined") { + resume( + Effect.fail(catalogError("open", "IndexedDB is unavailable in this browser context.")), + ); + return; + } + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { + request.result.createObjectStore(CATALOG_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { + request.result.createObjectStore(SHELL_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { + request.result.createObjectStore(THREAD_STORE_NAME); + } + }); + request.addEventListener("error", () => { + resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }); +}); + +function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { + return Effect.callback((resume) => { + const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); + request.addEventListener("error", () => { + resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue")); +} + +function writeDatabaseValue( + database: IDBDatabase, + storeName: string, + key: IDBValidKey, + value: unknown, +) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).put(value, key); + }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue")); +} + +function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).delete(key); + }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue")); +} + +function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + const request = transaction.objectStore(storeName).openCursor(range); + request.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")), + ); + }); + request.addEventListener("success", () => { + const cursor = request.result; + if (cursor === null) { + return; + } + cursor.delete(); + cursor.continue(); + }); + }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange")); +} + +function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { + return `${environmentId}:${threadId}`; +} + +const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) { + return yield* decodeConnectionCatalogDocument(raw).pipe( + Effect.mapError((cause) => catalogError("decode", cause)), + ); +}); + +const encodeCatalog = Effect.fn("web.connectionStorage.encodeCatalog")(function* ( + catalog: ConnectionCatalogDocumentType, +) { + return yield* encodeConnectionCatalogDocument(catalog).pipe( + Effect.mapError((cause) => catalogError("encode", cause)), + ); +}); + +export interface CatalogBackend { + readonly read: Effect.Effect; + readonly write: (raw: string) => Effect.Effect; + readonly quarantine?: (raw: string) => Effect.Effect; +} + +export function makeCatalogBackend(database: IDBDatabase): CatalogBackend { + const bridge = window.desktopBridge; + if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) { + return { + read: Effect.tryPromise({ + try: () => bridge.getConnectionCatalog!(), + catch: (cause) => catalogError("load", cause), + }), + write: (raw) => + Effect.tryPromise({ + try: () => bridge.setConnectionCatalog!(raw), + catch: (cause) => catalogError("save", cause), + }).pipe( + Effect.flatMap((stored) => + stored + ? Effect.void + : Effect.fail( + catalogError( + "save", + "Desktop secure storage is unavailable in this system context.", + ), + ), + ), + ), + }; + } + + return { + read: readDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY).pipe( + Effect.map((value) => (typeof value === "string" ? value : null)), + ), + write: (raw) => writeDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY, raw), + quarantine: (raw) => + writeDatabaseValue(database, CATALOG_STORE_NAME, `${CATALOG_KEY}:corrupt:${Date.now()}`, raw), + }; +} + +interface CatalogStore { + readonly read: Effect.Effect; + readonly update: ( + transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, + ) => Effect.Effect; +} + +export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStore")(function* ( + backend: CatalogBackend, +) { + const state = yield* Ref.make>(Option.none()); + const lock = yield* Semaphore.make(1); + + const loadUnlocked = Effect.fn("web.connectionStorage.loadCatalog")(function* () { + const cached = yield* Ref.get(state); + if (Option.isSome(cached)) { + return cached.value; + } + const raw = yield* backend.read; + let catalog = EMPTY_CONNECTION_CATALOG_DOCUMENT; + if (raw !== null && raw.trim() !== "") { + catalog = yield* decodeCatalog(raw).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning("Discarding a corrupt web connection catalog.", { + error: error.message, + }); + if (backend.quarantine !== undefined) { + yield* backend.quarantine(raw).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not quarantine the corrupt web connection catalog.", { + error: cause.message, + }), + ), + ); + } + const encoded = yield* encodeCatalog(EMPTY_CONNECTION_CATALOG_DOCUMENT); + yield* backend.write(encoded).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not persist the recovered web connection catalog.", { + error: cause.message, + }), + ), + ); + return EMPTY_CONNECTION_CATALOG_DOCUMENT; + }), + ), + ); + } + yield* Ref.set(state, Option.some(catalog)); + return catalog; + }); + + const read = lock.withPermits(1)(loadUnlocked()); + const update: CatalogStore["update"] = Effect.fn("web.connectionStorage.updateCatalog")( + function* (transform) { + yield* lock.withPermits(1)( + Effect.gen(function* () { + const next = transform(yield* loadUnlocked()); + yield* backend.write(yield* encodeCatalog(next)); + yield* Ref.set(state, Option.some(next)); + }), + ); + }, + ); + + return { read, update } satisfies CatalogStore; +}); + +export const connectionStorageLayer = Layer.effectContext( + Effect.gen(function* () { + const database = yield* Effect.acquireRelease(openDatabase(), (database) => + Effect.sync(() => database.close()), + ); + const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); + + const targetStore = ConnectionTargetStore.of({ + list: catalog.read.pipe( + Effect.map((document) => document.targets), + Effect.mapError((cause) => persistenceError("list-targets", cause)), + ), + }); + const registrationStore = ConnectionRegistrationStore.of({ + register: (registration) => + catalog + .update((document) => registerConnectionInCatalog(document, registration)) + .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))), + remove: (target) => + catalog + .update((document) => removeConnectionFromCatalog(document, target)) + .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), + }); + const profileStore = ProfileStore.make({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.profiles.find((profile) => profile.connectionId === connectionId), + ), + ), + ), + put: (profile) => + catalog.update((document) => ({ + ...document, + profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + profiles: removeCatalogValue( + document.profiles, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const credentialStore = CredentialStore.make({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.credentials.find((entry) => entry.connectionId === connectionId)?.credential, + ), + ), + ), + put: (connectionId, credential) => + catalog.update((document) => ({ + ...document, + credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, { + connectionId, + credential, + }), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + credentials: removeCatalogValue( + document.credentials, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const remoteTokenStore = TokenStore.make({ + get: (environmentId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.remoteDpopTokens.find((token) => token.environmentId === environmentId), + ), + ), + ), + put: (token) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: replaceCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + token, + ), + })), + remove: (environmentId) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + environmentId, + ), + })), + }); + const cacheStore = EnvironmentCacheStore.of({ + loadShell: (environmentId) => + readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "string") { + return Effect.succeed(Option.none()); + } + return decodeStoredShellSnapshot(raw).pipe( + Effect.mapError((cause) => persistenceError("load-shell", cause)), + Effect.map((stored) => + stored.environmentId === environmentId + ? Option.some(stored.snapshot) + : Option.none(), + ), + ); + }), + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("load-shell", cause), + ), + ), + saveShell: (environmentId, snapshot) => + Effect.gen(function* () { + const encoded = yield* encodeStoredShellSnapshot({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-shell", cause), + ), + ), + loadThread: (environmentId, threadId) => + readDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, threadId), + ).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "string") { + return Effect.succeed(Option.none()); + } + return decodeStoredThreadSnapshot(raw).pipe( + Effect.mapError((cause) => persistenceError("load-thread", cause)), + Effect.map((stored) => + stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.thread) + : Option.none(), + ), + ); + }), + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("load-thread", cause), + ), + ), + saveThread: (environmentId, thread) => + Effect.gen(function* () { + const encoded = yield* encodeStoredThreadSnapshot({ + schemaVersion: 1, + environmentId, + threadId: thread.id, + thread, + }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); + yield* writeDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, thread.id), + encoded, + ); + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-thread", cause), + ), + ), + removeThread: (environmentId, threadId) => + removeDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, threadId), + ).pipe(Effect.mapError((cause) => persistenceError("remove-thread", cause))), + clear: (environmentId) => + Effect.all( + [ + removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), + removeDatabaseValuesInRange( + database, + THREAD_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), + ], + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), + }); + + return Context.make(ConnectionTargetStore, targetStore).pipe( + Context.add(ConnectionRegistrationStore, registrationStore), + Context.add(ProfileStore.ConnectionProfileStore, profileStore), + Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), + Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(EnvironmentCacheStore, cacheStore), + ); + }), +); diff --git a/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts index 38032c07a88..9c358ab1d29 100644 --- a/apps/web/src/diffFileActions.test.ts +++ b/apps/web/src/diffFileActions.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; diff --git a/apps/web/src/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts new file mode 100644 index 00000000000..64846e8e9f1 --- /dev/null +++ b/apps/web/src/diffPanelStore.test.ts @@ -0,0 +1,68 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore"; + +const THREAD_REF = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); + +describe("diffPanelStore", () => { + beforeEach(() => useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} })); + + it("defaults each thread to branch changes with automatic base selection", () => { + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: null }); + }); + + it("clears incompatible selection fields when changing scopes", () => { + const store = useDiffPanelStore.getState(); + store.selectTurn(THREAD_REF, TurnId.make("turn-1"), "src/app.ts"); + store.selectGitScope(THREAD_REF, "unstaged"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "unstaged" }); + + useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, " origin/main "); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: "origin/main" }); + }); + + it("increments the reveal request when opening the same turn file again", () => { + const turnId = TurnId.make("turn-1"); + useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); + useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "turn", turnId, filePath: "src/app.ts", revealRequestId: 2 }); + }); + + it("restores the selected branch base after visiting another scope", () => { + useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, "origin/main"); + useDiffPanelStore.getState().selectGitScope(THREAD_REF, "unstaged"); + useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: "origin/main" }); + }); + + it("reconciles a missing turn selection to the latest available turn", () => { + const missingTurnId = TurnId.make("turn-missing"); + const latestTurnId = TurnId.make("turn-latest"); + useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts"); + useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ + kind: "turn", + turnId: latestTurnId, + filePath: "src/app.ts", + revealRequestId: 1, + }); + }); +}); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts new file mode 100644 index 00000000000..c946b286d1b --- /dev/null +++ b/apps/web/src/diffPanelStore.ts @@ -0,0 +1,139 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; + +export type DiffPanelSelection = + | { kind: "branch"; baseRef: string | null } + | { kind: "unstaged" } + | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; + +const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; + +interface DiffPanelStoreState { + byThreadKey: Record; + branchBaseRefByThreadKey: Record; + selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; + selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; + selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; + reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +function normalizeBaseRef(baseRef: string | null): string | null { + const normalized = baseRef?.trim(); + return normalized ? normalized : null; +} + +export const useDiffPanelStore = create()( + persist( + (set) => ({ + byThreadKey: {}, + branchBaseRefByThreadKey: {}, + selectGitScope: (ref, scope) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const previous = state.byThreadKey[threadKey]; + const previousBaseRef = + previous?.kind === "branch" + ? previous.baseRef + : (state.branchBaseRefByThreadKey[threadKey] ?? null); + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: + scope === "branch" + ? { kind: "branch", baseRef: previousBaseRef } + : { kind: "unstaged" }, + }, + branchBaseRefByThreadKey: + previous?.kind === "branch" + ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef } + : state.branchBaseRefByThreadKey, + }; + }), + selectBranchBaseRef: (ref, baseRef) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const normalizedBaseRef = normalizeBaseRef(baseRef); + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { kind: "branch", baseRef: normalizedBaseRef }, + }, + branchBaseRefByThreadKey: { + ...state.branchBaseRefByThreadKey, + [threadKey]: normalizedBaseRef, + }, + }; + }), + selectTurn: (ref, turnId, filePath) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const previous = state.byThreadKey[threadKey]; + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { + kind: "turn", + turnId, + filePath: filePath?.trim() || null, + revealRequestId: previous?.kind === "turn" ? previous.revealRequestId + 1 : 1, + }, + }, + }; + }), + reconcileTurnSelection: (ref, availableTurnIds) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const previous = state.byThreadKey[threadKey]; + const latestTurnId = availableTurnIds[0]; + if ( + previous?.kind !== "turn" || + latestTurnId === undefined || + availableTurnIds.includes(previous.turnId) + ) { + return state; + } + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { ...previous, turnId: latestTurnId }, + }, + }; + }), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + if (!(threadKey in state.byThreadKey) && !(threadKey in state.branchBaseRefByThreadKey)) { + return state; + } + const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey; + const { [threadKey]: _removedBaseRef, ...branchBaseRefByThreadKey } = + state.branchBaseRefByThreadKey; + return { byThreadKey, branchBaseRefByThreadKey }; + }), + }), + { + name: "t3code:diff-panel-state:v1", + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ + byThreadKey: state.byThreadKey, + branchBaseRefByThreadKey: state.branchBaseRefByThreadKey, + }), + }, + ), +); + +export function selectThreadDiffPanelSelection( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, +): DiffPanelSelection { + if (!ref) return DEFAULT_SELECTION; + return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION; +} diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts deleted file mode 100644 index c80368eeea4..00000000000 --- a/apps/web/src/diffRouteSearch.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { parseDiffRouteSearch } from "./diffRouteSearch"; - -describe("parseDiffRouteSearch", () => { - it("parses valid diff search values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - }); - - it("treats numeric and boolean diff toggles as open", () => { - expect( - parseDiffRouteSearch({ - diff: 1, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - - expect( - parseDiffRouteSearch({ - diff: true, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - }); - - it("drops turn and file values when diff is closed", () => { - const parsed = parseDiffRouteSearch({ - diff: "0", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({}); - }); - - it("drops file value when turn is not selected", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); - - it("normalizes whitespace-only values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: " ", - diffFilePath: " ", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); -}); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts deleted file mode 100644 index d9b072f28e1..00000000000 --- a/apps/web/src/diffRouteSearch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TurnId } from "@t3tools/contracts"; - -export interface DiffRouteSearch { - diff?: "1" | undefined; - diffTurnId?: TurnId | undefined; - diffFilePath?: string | undefined; -} - -function isDiffOpenValue(value: unknown): boolean { - return value === "1" || value === 1 || value === true; -} - -function normalizeSearchString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -export function stripDiffSearchParams>( - params: T, -): Omit { - const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; -} - -export function parseDiffRouteSearch(search: Record): DiffRouteSearch { - const diff = isDiffOpenValue(search.diff) ? "1" : undefined; - const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; - const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; - const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; - - return { - ...(diff ? { diff } : {}), - ...(diffTurnId ? { diffTurnId } : {}), - ...(diffFilePath ? { diffFilePath } : {}), - }; -} diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 38c59115a55..d691ddb3153 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,9 +1,43 @@ -import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; +import { EDITORS, EditorId, EnvironmentId } from "@t3tools/contracts"; +import { + mapAtomCommandResult, + type AtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; +import { AsyncResult } from "effect/unstable/reactivity"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; +import { shellEnvironment } from "./state/shell"; +import { useAtomCommand } from "./state/use-atom-command"; const LAST_EDITOR_KEY = "t3code:last-editor"; +export class PreferredEditorEnvironmentRequiredError extends Schema.TaggedErrorClass()( + "PreferredEditorEnvironmentRequiredError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Cannot open ${this.targetPath} because no environment is selected.`; + } +} + +export class PreferredEditorUnavailableError extends Schema.TaggedErrorClass()( + "PreferredEditorUnavailableError", + { + environmentId: EnvironmentId, + targetPath: Schema.String, + availableEditorIds: Schema.Array(EditorId), + }, +) { + override get message(): string { + return `No available editor can open ${this.targetPath} in environment ${this.environmentId}.`; + } +} + export function usePreferredEditor(availableEditors: ReadonlyArray) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); @@ -26,10 +60,56 @@ export function resolveAndPersistPreferredEditor( return editor ?? null; } -export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise { - const { availableEditors } = await api.server.getConfig(); - const editor = resolveAndPersistPreferredEditor(availableEditors); - if (!editor) throw new Error("No available editors found."); - await api.shell.openInEditor(targetPath, editor); - return editor; +export function useOpenInPreferredEditor( + environmentId: EnvironmentId | null, + availableEditors: readonly EditorId[], +) { + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + type OpenInEditorError = AtomCommandFailure>>; + + return useCallback( + async ( + targetPath: string, + ): Promise< + AtomCommandResult< + EditorId, + | OpenInEditorError + | PreferredEditorEnvironmentRequiredError + | PreferredEditorUnavailableError + > + > => { + if (environmentId === null) { + return AsyncResult.failure( + Cause.fail( + new PreferredEditorEnvironmentRequiredError({ + targetPath, + }), + ), + ); + } + const editor = resolveAndPersistPreferredEditor(availableEditors); + if (!editor) { + return AsyncResult.failure( + Cause.fail( + new PreferredEditorUnavailableError({ + environmentId, + targetPath, + availableEditorIds: availableEditors, + }), + ), + ); + } + const result = await openInEditor({ + environmentId, + input: { + cwd: targetPath, + editor, + }, + }); + return mapAtomCommandResult(result, () => editor); + }, + [availableEditors, environmentId, openInEditor], + ); } diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts deleted file mode 100644 index ae373ac94f9..00000000000 --- a/apps/web/src/environmentApi.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; - -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { readEnvironmentConnection } from "./environments/runtime"; - -const environmentApiOverridesForTests = new Map(); - -export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { - return { - terminal: { - open: (input) => rpcClient.terminal.open(input as never), - attach: (input, callback, options) => - rpcClient.terminal.attach(input as never, callback, options), - write: (input) => rpcClient.terminal.write(input as never), - resize: (input) => rpcClient.terminal.resize(input as never), - clear: (input) => rpcClient.terminal.clear(input as never), - restart: (input) => rpcClient.terminal.restart(input as never), - close: (input) => rpcClient.terminal.close(input as never), - onMetadata: (callback, options) => rpcClient.terminal.onMetadata(callback, options), - }, - projects: { - listEntries: rpcClient.projects.listEntries, - readFile: rpcClient.projects.readFile, - searchEntries: rpcClient.projects.searchEntries, - writeFile: rpcClient.projects.writeFile, - }, - filesystem: { - browse: rpcClient.filesystem.browse, - }, - assets: { - createUrl: rpcClient.assets.createUrl, - }, - sourceControl: { - lookupRepository: rpcClient.sourceControl.lookupRepository, - cloneRepository: rpcClient.sourceControl.cloneRepository, - publishRepository: rpcClient.sourceControl.publishRepository, - }, - vcs: { - pull: rpcClient.vcs.pull, - refreshStatus: rpcClient.vcs.refreshStatus, - onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), - listRefs: rpcClient.vcs.listRefs, - createWorktree: rpcClient.vcs.createWorktree, - removeWorktree: rpcClient.vcs.removeWorktree, - createRef: rpcClient.vcs.createRef, - switchRef: rpcClient.vcs.switchRef, - init: rpcClient.vcs.init, - }, - git: { - resolvePullRequest: rpcClient.git.resolvePullRequest, - preparePullRequestThread: rpcClient.git.preparePullRequestThread, - }, - review: { - getDiffPreview: rpcClient.review.getDiffPreview, - }, - orchestration: { - dispatchCommand: rpcClient.orchestration.dispatchCommand, - getTurnDiff: rpcClient.orchestration.getTurnDiff, - getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, - getArchivedShellSnapshot: rpcClient.orchestration.getArchivedShellSnapshot, - subscribeShell: (callback, options) => - rpcClient.orchestration.subscribeShell(callback, options), - subscribeThread: (input, callback, options) => - rpcClient.orchestration.subscribeThread(input, callback, options), - }, - preview: { - open: (input) => rpcClient.preview.open(input as never), - navigate: (input) => rpcClient.preview.navigate(input as never), - refresh: (input) => rpcClient.preview.refresh(input as never), - close: (input) => rpcClient.preview.close(input as never), - list: (input) => rpcClient.preview.list(input as never), - reportStatus: (input) => rpcClient.preview.reportStatus(input as never), - automation: { - connect: (input, callback, options) => - rpcClient.preview.automation.connect(input as never, callback, options), - respond: (response) => rpcClient.preview.automation.respond(response as never), - reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), - clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), - }, - onEvent: (callback, options) => rpcClient.preview.onEvent(callback, options), - subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), - }, - }; -} - -export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined { - if (typeof window === "undefined") { - return undefined; - } - - if (!environmentId) { - return undefined; - } - - const overriddenApi = environmentApiOverridesForTests.get(environmentId); - if (overriddenApi) { - return overriddenApi; - } - - const connection = readEnvironmentConnection(environmentId); - return connection ? createEnvironmentApi(connection.client) : undefined; -} - -export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { - const api = readEnvironmentApi(environmentId); - if (!api) { - throw new Error(`Environment API not found for environment ${environmentId}`); - } - return api; -} - -export function __setEnvironmentApiOverrideForTests( - environmentId: EnvironmentId, - api: EnvironmentApi, -): void { - environmentApiOverridesForTests.set(environmentId, api); -} - -export function __resetEnvironmentApiOverridesForTests(): void { - environmentApiOverridesForTests.clear(); -} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 53f1b3c2d09..c66bf4977b2 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -1,54 +1,40 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - selectProjectsAcrossEnvironments, - selectSidebarThreadsAcrossEnvironments, - selectSidebarThreadsForProjectRef, - selectSidebarThreadsForProjectRefs, - type AppState, - type EnvironmentState, -} from "./store"; import { deriveLogicalProjectKey, deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, resolveProjectGroupingMode, } from "./logicalProject"; -import type { Project, SidebarThreadSummary } from "./types"; -import { DEFAULT_INTERACTION_MODE } from "./types"; - -// ── Fixture Identifiers ────────────────────────────────────────────── - -const primaryEnvId = EnvironmentId.make("env-primary"); -const remoteEnvId = EnvironmentId.make("env-remote"); - -const sharedProjectPrimaryId = ProjectId.make("shared-proj-primary"); -const sharedProjectRemoteId = ProjectId.make("shared-proj-remote"); -const localOnlyProjectId = ProjectId.make("local-only-proj"); -const remoteOnlyProjectId = ProjectId.make("remote-only-proj"); - -const threadP1 = ThreadId.make("thread-shared-primary-1"); -const threadP2 = ThreadId.make("thread-shared-primary-2"); -const threadR1 = ThreadId.make("thread-shared-remote-1"); -const threadL1 = ThreadId.make("thread-local-only-1"); -const threadRO1 = ThreadId.make("thread-remote-only-1"); - -const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; -const DEFAULT_GROUPING_SETTINGS = { +import type { Project } from "./types"; + +const primaryEnvironmentId = EnvironmentId.make("env-primary"); +const remoteEnvironmentId = EnvironmentId.make("env-remote"); +const repositoryIdentity = { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, +}; +const defaultGroupingSettings = { sidebarProjectGroupingMode: "repository" as const, sidebarProjectGroupingOverrides: {}, }; -// ── Factory Helpers ────────────────────────────────────────────────── - -function makeProject( - overrides: Partial & Pick, -): Project { +function makeProject(overrides: Partial = {}): Project { return { - cwd: `/tmp/${overrides.name}`, - defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + id: ProjectId.make("project-1"), + environmentId: primaryEnvironmentId, + title: "shared-repo", + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity: null, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", scripts: [], @@ -56,560 +42,81 @@ function makeProject( }; } -function makeSidebarThreadSummary( - overrides: Partial & - Pick, -): SidebarThreadSummary { - return { - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - createdAt: "2026-01-01T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-01-01T00:00:00.000Z", - latestTurn: null, - branch: null, - worktreePath: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - goal: null, - ...overrides, - }; -} - -function makeEmptyEnvironmentState(): EnvironmentState { - return { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; -} - -// ── Fixture: Two environments, shared + local-only + remote-only projects ── - -function makeFixtureState(): AppState { - // Shared project: same repo in both envs - const sharedProjectPrimary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const sharedProjectRemote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - // Local-only project - const localOnlyProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - // Remote-only project - const remoteOnlyProject = makeProject({ - id: remoteOnlyProjectId, - environmentId: remoteEnvId, - name: "remote-only", - }); - - // Threads - const summaryP1 = makeSidebarThreadSummary({ - id: threadP1, - environmentId: primaryEnvId, - projectId: sharedProjectPrimaryId, - title: "Shared primary thread 1", - }); - const summaryP2 = makeSidebarThreadSummary({ - id: threadP2, - environmentId: primaryEnvId, - projectId: sharedProjectPrimaryId, - title: "Shared primary thread 2", - }); - const summaryR1 = makeSidebarThreadSummary({ - id: threadR1, - environmentId: remoteEnvId, - projectId: sharedProjectRemoteId, - title: "Shared remote thread 1", - }); - const summaryL1 = makeSidebarThreadSummary({ - id: threadL1, - environmentId: primaryEnvId, - projectId: localOnlyProjectId, - title: "Local only thread 1", - }); - const summaryRO1 = makeSidebarThreadSummary({ - id: threadRO1, - environmentId: remoteEnvId, - projectId: remoteOnlyProjectId, - title: "Remote only thread 1", - }); - - const primaryEnvState: EnvironmentState = { - ...makeEmptyEnvironmentState(), - projectIds: [sharedProjectPrimaryId, localOnlyProjectId], - projectById: { - [sharedProjectPrimaryId]: sharedProjectPrimary, - [localOnlyProjectId]: localOnlyProject, - }, - threadIds: [threadP1, threadP2, threadL1], - threadIdsByProjectId: { - [sharedProjectPrimaryId]: [threadP1, threadP2], - [localOnlyProjectId]: [threadL1], - }, - sidebarThreadSummaryById: { - [threadP1]: summaryP1, - [threadP2]: summaryP2, - [threadL1]: summaryL1, - }, - }; - - const remoteEnvState: EnvironmentState = { - ...makeEmptyEnvironmentState(), - projectIds: [sharedProjectRemoteId, remoteOnlyProjectId], - projectById: { - [sharedProjectRemoteId]: sharedProjectRemote, - [remoteOnlyProjectId]: remoteOnlyProject, - }, - threadIds: [threadR1, threadRO1], - threadIdsByProjectId: { - [sharedProjectRemoteId]: [threadR1], - [remoteOnlyProjectId]: [threadRO1], - }, - sidebarThreadSummaryById: { - [threadR1]: summaryR1, - [threadRO1]: summaryRO1, - }, - }; - - return { - activeEnvironmentId: primaryEnvId, - environmentStateById: { - [primaryEnvId]: primaryEnvState, - [remoteEnvId]: remoteEnvState, - }, - }; -} - -// ── Tests ──────────────────────────────────────────────────────────── - describe("environment grouping", () => { - describe("deriveLogicalProjectKey", () => { - it("uses repositoryIdentity.canonicalKey when present", () => { - const project = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - expect(deriveLogicalProjectKey(project)).toBe(SHARED_REPO_CANONICAL_KEY); - }); - - it("falls back to scoped project key when no repositoryIdentity", () => { - const project = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); - }); - - it("groups projects from different environments that share the same canonical key", () => { - const primary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const remote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); - }); - - it("groups repo root and nested projects from the same repository by default", () => { - const rootProject = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - cwd: "/workspace/repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const nestedProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); - expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); - }); - - it("uses repository path grouping when requested", () => { - const rootProject = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - cwd: "/workspace/repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const nestedProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect( - deriveLogicalProjectKey(rootProject, { - groupingMode: "repository_path", - }), - ).toBe(SHARED_REPO_CANONICAL_KEY); - expect( - deriveLogicalProjectKey(nestedProject, { - groupingMode: "repository_path", - }), - ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); - }); - - it("groups matching nested project paths across environments when repo roots differ", () => { - const primary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const remote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "web", - cwd: "/srv/checkout/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/srv/checkout", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect( - deriveLogicalProjectKey(primary, { - groupingMode: "repository_path", - }), - ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); - expect( - deriveLogicalProjectKey(primary, { - groupingMode: "repository_path", - }), - ).toBe( - deriveLogicalProjectKey(remote, { - groupingMode: "repository_path", - }), - ); + it("groups matching repository identities across environments", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, }); - it("does NOT group projects without shared canonical key", () => { - const local = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - const remote = makeProject({ - id: remoteOnlyProjectId, - environmentId: remoteEnvId, - name: "remote-only", - }); - expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); - }); - - it("uses per-project overrides from settings", () => { - const project = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); - expect( - deriveLogicalProjectKeyFromSettings(project, { - ...DEFAULT_GROUPING_SETTINGS, - sidebarProjectGroupingOverrides: { - [derivePhysicalProjectKey(project)]: "separate", - }, - }), - ).toBe(derivePhysicalProjectKey(project)); - }); + expect(deriveLogicalProjectKey(primary)).toBe(repositoryIdentity.canonicalKey); + expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey); }); - describe("selectProjectsAcrossEnvironments", () => { - it("returns all projects from all environments", () => { - const state = makeFixtureState(); - const projects = selectProjectsAcrossEnvironments(state); - expect(projects).toHaveLength(4); - const names = projects.map((p) => p.name).toSorted(); - expect(names).toEqual(["local-only", "remote-only", "shared-repo", "shared-repo"]); + it("keeps projects without repository identity physically scoped", () => { + const primary = makeProject(); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, }); - }); - describe("selectSidebarThreadsAcrossEnvironments", () => { - it("returns all sidebar thread summaries from all environments", () => { - const state = makeFixtureState(); - const threads = selectSidebarThreadsAcrossEnvironments(state); - expect(threads).toHaveLength(5); - const ids = new Set(threads.map((t) => t.id)); - expect(ids).toContain(threadP1); - expect(ids).toContain(threadP2); - expect(ids).toContain(threadR1); - expect(ids).toContain(threadL1); - expect(ids).toContain(threadRO1); - }); + expect(deriveLogicalProjectKey(primary)).toBe(derivePhysicalProjectKey(primary)); + expect(deriveLogicalProjectKey(remote)).toBe(derivePhysicalProjectKey(remote)); + expect(deriveLogicalProjectKey(primary)).not.toBe(deriveLogicalProjectKey(remote)); }); - describe("selectSidebarThreadsForProjectRef", () => { - it("returns threads for a single project ref", () => { - const state = makeFixtureState(); - const ref = scopeProjectRef(primaryEnvId, sharedProjectPrimaryId); - const threads = selectSidebarThreadsForProjectRef(state, ref); - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); - - it("returns empty array for null ref", () => { - const state = makeFixtureState(); - expect(selectSidebarThreadsForProjectRef(state, null)).toEqual([]); - }); + it("uses the physical key when repository grouping is disabled", () => { + const project = makeProject({ repositoryIdentity }); - it("returns empty array for nonexistent environment", () => { - const state = makeFixtureState(); - const ref = scopeProjectRef(EnvironmentId.make("nonexistent"), sharedProjectPrimaryId); - expect(selectSidebarThreadsForProjectRef(state, ref)).toEqual([]); - }); + expect( + deriveLogicalProjectKeyFromSettings(project, { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: {}, + }), + ).toBe(derivePhysicalProjectKey(project)); }); - describe("selectSidebarThreadsForProjectRefs", () => { - it("returns empty for empty refs", () => { - const state = makeFixtureState(); - expect(selectSidebarThreadsForProjectRefs(state, [])).toEqual([]); - }); - - it("returns threads for a single ref", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(primaryEnvId, sharedProjectPrimaryId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); - - it("returns combined threads from multiple refs across environments", () => { - const state = makeFixtureState(); - const refs = [ - scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), - scopeProjectRef(remoteEnvId, sharedProjectRemoteId), - ]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(3); - const ids = new Set(threads.map((t) => t.id)); - expect(ids).toContain(threadP1); - expect(ids).toContain(threadP2); - expect(ids).toContain(threadR1); - }); - - it("returns threads from remote-only project", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(remoteEnvId, remoteOnlyProjectId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(1); - expect(threads[0]?.id).toBe(threadRO1); - }); - - it("returns threads from local-only project", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(primaryEnvId, localOnlyProjectId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(1); - expect(threads[0]?.id).toBe(threadL1); - }); + it("allows a per-project override to separate an otherwise grouped repository", () => { + const project = makeProject({ repositoryIdentity }); + const physicalKey = derivePhysicalProjectKey(project); - it("handles refs with nonexistent environment gracefully", () => { - const state = makeFixtureState(); - const refs = [ - scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), - scopeProjectRef(EnvironmentId.make("nonexistent"), ProjectId.make("nope")), - ]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - // Only returns threads from the valid ref - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); + expect( + deriveLogicalProjectKeyFromSettings(project, { + ...defaultGroupingSettings, + sidebarProjectGroupingOverrides: { + [physicalKey]: "separate", + }, + }), + ).toBe(physicalKey); }); - describe("logical project grouping for sidebar", () => { - it("computes correct logical key for grouped projects and aggregates threads", () => { - const state = makeFixtureState(); - const allProjects = selectProjectsAcrossEnvironments(state); - - // Group by logical key - const groups = new Map(); - for (const project of allProjects) { - const key = deriveLogicalProjectKey(project); - const existing = groups.get(key) ?? []; - existing.push(project); - groups.set(key, existing); - } - - // Shared project should be grouped - const sharedGroup = groups.get(SHARED_REPO_CANONICAL_KEY); - expect(sharedGroup).toBeDefined(); - expect(sharedGroup).toHaveLength(2); - expect(sharedGroup!.map((p) => p.environmentId).toSorted()).toEqual( - [primaryEnvId, remoteEnvId].toSorted(), - ); - - // Build member refs for the grouped project and fetch threads - const memberRefs = sharedGroup!.map((p) => scopeProjectRef(p.environmentId, p.id)); - const threads = selectSidebarThreadsForProjectRefs(state, memberRefs); - expect(threads).toHaveLength(3); - const threadIds = threads.map((t) => t.id); - expect(threadIds).toContain(threadP1); - expect(threadIds).toContain(threadP2); - expect(threadIds).toContain(threadR1); - }); + it("allows a per-project override to group a repository while the global mode is separate", () => { + const project = makeProject({ repositoryIdentity }); - it("local-only and remote-only projects remain ungrouped", () => { - const state = makeFixtureState(); - const allProjects = selectProjectsAcrossEnvironments(state); - - const groups = new Map(); - for (const project of allProjects) { - const key = deriveLogicalProjectKey(project); - const existing = groups.get(key) ?? []; - existing.push(project); - groups.set(key, existing); - } - - // Should have 3 groups total: shared, local-only, remote-only - expect(groups.size).toBe(3); + expect( + deriveLogicalProjectKeyFromSettings(project, { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: { + [derivePhysicalProjectKey(project)]: "repository", + }, + }), + ).toBe(repositoryIdentity.canonicalKey); + }); - // Local-only group - const localKey = deriveLogicalProjectKey( - allProjects.find((p) => p.id === localOnlyProjectId)!, - ); - expect(groups.get(localKey)).toHaveLength(1); + it("reports the effective grouping mode after applying an override", () => { + const project = makeProject({ repositoryIdentity }); + const physicalKey = derivePhysicalProjectKey(project); - // Remote-only group - const remoteKey = deriveLogicalProjectKey( - allProjects.find((p) => p.id === remoteOnlyProjectId)!, - ); - expect(groups.get(remoteKey)).toHaveLength(1); - }); + expect(resolveProjectGroupingMode(project, defaultGroupingSettings)).toBe("repository"); + expect( + resolveProjectGroupingMode(project, { + ...defaultGroupingSettings, + sidebarProjectGroupingOverrides: { + [physicalKey]: "separate", + }, + }), + ).toBe("separate"); }); }); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f6f07dbb303..96814b92b79 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -21,15 +21,100 @@ import { import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; -import * as Data from "effect/Data"; -import * as Predicate from "effect/Predicate"; - -export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ - readonly message: string; - readonly status: number; -}> {} -const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => - Predicate.isTagged(u, "BootstrapHttpError"); + +const PrimaryEnvironmentRequestOperation = Schema.Literals([ + "fetch-session-state", + "exchange-bootstrap-credential", + "fetch-environment-descriptor", + "create-pairing-credential", + "list-pairing-links", + "revoke-pairing-link", + "list-client-sessions", + "revoke-client-session", + "revoke-other-client-sessions", +]); +type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; + +export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentRequestError", + { + operation: PrimaryEnvironmentRequestOperation, + status: Schema.Number, + pairingLinkId: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly operation: PrimaryEnvironmentRequestOperation; + readonly cause: unknown; + readonly pairingLinkId?: string; + readonly sessionId?: string; + }): PrimaryEnvironmentRequestError { + const status = readHttpApiStatus(input.cause) ?? 500; + return new PrimaryEnvironmentRequestError({ + operation: input.operation, + status, + ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), + ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), + cause: input.cause, + }); + } + + override get message(): string { + return `Primary environment request failed during ${this.operation} (HTTP ${this.status}).`; + } +} + +export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError); + +export class PrimaryEnvironmentPairingCredentialRejectedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRejectedError", + { + providedLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid pairing token. Check the token and try again."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is( + PrimaryEnvironmentPairingCredentialRejectedError, +); + +export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentAuthSessionTimeoutError", + { + timeoutMs: Schema.Number, + elapsedMs: Schema.Number, + }, +) { + override get message(): string { + return "Timed out waiting for authenticated session after bootstrap."; + } +} + +export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is( + PrimaryEnvironmentAuthSessionTimeoutError, +); + +export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRequiredError", + { + providedLength: Schema.Number, + }, +) { + override get message(): string { + return "Enter a pairing token to continue."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is( + PrimaryEnvironmentPairingCredentialRequiredError, +); + const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -106,10 +191,9 @@ export async function fetchSessionState(): Promise { ), ); } catch (error) { - const status = readHttpApiStatus(error); - throw new BootstrapHttpError({ - message: `Failed to load server auth session state (${status ?? "unknown"}).`, - status: status ?? 500, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, }); } }); @@ -138,42 +222,6 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): } } -function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string { - if (!isEnvironmentHttpCommonError(error)) { - return fallbackMessage; - } - switch (error._tag) { - case "EnvironmentAuthInvalidError": - return error.reason === "missing_credential" - ? "Authentication required." - : "Invalid bootstrap credential."; - case "EnvironmentRequestInvalidError": - return error.reason === "invalid_scope" - ? "Requested token scope is invalid." - : "Requested scope exceeds the bootstrap credential grant."; - case "EnvironmentScopeRequiredError": - return `The authenticated token is missing required scope: ${error.requiredScope}.`; - case "EnvironmentOperationForbiddenError": - return "This operation is not allowed for the current session."; - case "EnvironmentInternalError": - return fallbackMessage; - } -} - -const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([ - "Invalid bootstrap credential.", - "Unknown bootstrap credential.", -]); - -function toFriendlyBootstrapErrorMessage(status: number, message: string): string { - const trimmedMessage = message.trim(); - if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) { - return "Invalid pairing token. Check the token and try again."; - } - - return trimmedMessage; -} - async function exchangeBootstrapCredential(credential: string): Promise { return retryTransientBootstrap(async () => { try { @@ -183,11 +231,19 @@ async function exchangeBootstrapCredential(credential: string): Promise= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { - throw new Error("Timed out waiting for authenticated session after bootstrap."); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { + throw new PrimaryEnvironmentAuthSessionTimeoutError({ + timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS, + elapsedMs, + }); } await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); @@ -240,7 +300,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { } function isTransientBootstrapError(error: unknown): boolean { - if (isBootstrapHttpError(error)) { + if (isPrimaryEnvironmentRequestError(error)) { return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); } @@ -281,7 +341,9 @@ async function bootstrapServerAuth(): Promise { export async function submitServerAuthCredential(credential: string): Promise { const trimmedCredential = credential.trim(); if (!trimmedCredential) { - throw new Error("Enter a pairing token to continue."); + throw new PrimaryEnvironmentPairingCredentialRequiredError({ + providedLength: credential.length, + }); } resolvedAuthenticatedGateState = null; @@ -310,13 +372,10 @@ export async function createServerPairingCredential(input?: { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "create-pairing-credential", + cause: error, + }); } } @@ -353,13 +412,10 @@ export async function listServerPairingLinks(): Promise { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-pairing-link", + pairingLinkId: id, + cause: error, + }); } } @@ -406,13 +460,10 @@ export async function listServerClientSessions(): Promise< current: clientSession.current, })); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause: error, + }); } } @@ -426,13 +477,11 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-client-session", + sessionId, + cause: error, + }); } } @@ -445,13 +494,10 @@ export async function revokeOtherServerClientSessions(): Promise { ); return result.revokedCount; } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-other-client-sessions", + cause: error, + }); } } diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index f3a5c2678ac..e8333c2e078 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { getPrimaryKnownEnvironment, + isDesktopEnvironmentBootstrapIncompleteError, + isPrimaryEnvironmentProtocolUnsupportedError, + isPrimaryEnvironmentUrlInvalidError, + readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, resolveInitialPrimaryEnvironmentDescriptor, resetPrimaryEnvironmentDescriptorForTests, @@ -43,6 +47,15 @@ function installTestBrowser(url: string) { }); } +function captureThrown(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + throw new Error("Expected the operation to throw."); +} + describe("environmentBootstrap", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -175,4 +188,67 @@ describe("environmentBootstrap", () => { "http://127.0.0.1:5733/.well-known/t3/environment", ); }); + + it("retains the URL parser cause without exposing the configured URL in its message", () => { + vi.stubEnv("VITE_HTTP_URL", "http://["); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isPrimaryEnvironmentUrlInvalidError(error)).toBe(true); + if (!isPrimaryEnvironmentUrlInvalidError(error)) { + throw new Error("Expected a structured primary environment URL error."); + } + expect(error).toMatchObject({ + source: "configured", + urlKind: "http-base-url", + message: "Could not parse http-base-url for the configured primary environment target.", + }); + expect(error.cause).toBeInstanceOf(TypeError); + expect(error.message).not.toContain("http://["); + }); + + it("describes which desktop bootstrap endpoint is missing", () => { + vi.stubGlobal("window", { + location: new URL("http://127.0.0.1:5733/"), + history: { replaceState: vi.fn() }, + desktopBridge: { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + }, + }); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isDesktopEnvironmentBootstrapIncompleteError(error)).toBe(true); + if (!isDesktopEnvironmentBootstrapIncompleteError(error)) { + throw new Error("Expected a structured desktop bootstrap error."); + } + expect(error).toMatchObject({ + hasHttpBaseUrl: true, + hasWsBaseUrl: false, + message: "Desktop bootstrap is missing wsBaseUrl for the local environment.", + }); + }); + + it("preserves an unsupported window-origin protocol", () => { + vi.stubGlobal("window", { + location: { origin: "file:///tmp/t3code/" }, + history: { replaceState: vi.fn() }, + }); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isPrimaryEnvironmentProtocolUnsupportedError(error)).toBe(true); + if (!isPrimaryEnvironmentProtocolUnsupportedError(error)) { + throw new Error("Expected a structured primary environment protocol error."); + } + expect(error).toMatchObject({ + source: "window-origin", + protocol: "file:", + message: "The window-origin primary environment target uses unsupported protocol file:.", + }); + }); }); diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index db4406ecee0..e1021a7feb4 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -2,30 +2,17 @@ import { attachEnvironmentDescriptor, createKnownEnvironment, type KnownEnvironment, -} from "@t3tools/client-runtime"; -import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +} from "@t3tools/client-runtime/environment"; +import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { HttpClientError } from "effect/unstable/http"; -import { create } from "zustand"; -import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; +import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; import { readPrimaryEnvironmentTarget } from "./target"; -interface PrimaryEnvironmentBootstrapState { - readonly descriptor: ExecutionEnvironmentDescriptor | null; - readonly setDescriptor: (descriptor: ExecutionEnvironmentDescriptor | null) => void; - readonly reset: () => void; -} - -const usePrimaryEnvironmentBootstrapStore = create()((set) => ({ - descriptor: null, - setDescriptor: (descriptor) => set({ descriptor }), - reset: () => set({ descriptor: null }), -})); - +let primaryEnvironmentDescriptor: ExecutionEnvironmentDescriptor | null = null; let primaryEnvironmentDescriptorPromise: Promise | null = null; function createPrimaryKnownEnvironment(input: { @@ -56,13 +43,9 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise client.metadata.descriptor())), ); } catch (error) { - const status = - HttpClientError.isHttpClientError(error) && error.response !== undefined - ? error.response.status - : 500; - throw new BootstrapHttpError({ - message: `Failed to load server environment descriptor (${status}).`, - status, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-environment-descriptor", + cause: error, }); } @@ -72,17 +55,13 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise state.descriptor?.environmentId ?? null); + return primaryEnvironmentDescriptor; } export function writePrimaryEnvironmentDescriptor( descriptor: ExecutionEnvironmentDescriptor | null, ): void { - usePrimaryEnvironmentBootstrapStore.getState().setDescriptor(descriptor); + primaryEnvironmentDescriptor = descriptor; } export function getPrimaryKnownEnvironment(): KnownEnvironment | null { @@ -118,7 +97,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise { + beforeEach(() => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: {}, + }); + }); + + afterEach(() => { + __resetDesktopPrimaryAuthForTests(); + Reflect.deleteProperty(globalThis, "window"); + }); + + it("reuses the main-process bearer token across renderer requests", async () => { + const getLocalEnvironmentBearerToken = vi.fn().mockResolvedValue("desktop-bearer-token"); + window.desktopBridge = { + getLocalEnvironmentBearerToken, + } as unknown as DesktopBridge; + + await expect(readDesktopPrimaryBearerToken()).resolves.toBe("desktop-bearer-token"); + await expect(readDesktopPrimaryBearerToken()).resolves.toBe("desktop-bearer-token"); + expect(getLocalEnvironmentBearerToken).toHaveBeenCalledTimes(1); + }); + + it("does not require desktop auth in a browser", async () => { + await expect(readDesktopPrimaryBearerToken()).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/environments/primary/desktopAuth.ts b/apps/web/src/environments/primary/desktopAuth.ts new file mode 100644 index 00000000000..325773d910d --- /dev/null +++ b/apps/web/src/environments/primary/desktopAuth.ts @@ -0,0 +1,21 @@ +let desktopBearerTokenPromise: Promise | null = null; + +export function readDesktopPrimaryBearerToken(): Promise { + if (typeof window === "undefined") { + return Promise.resolve(null); + } + const bridge = window.desktopBridge; + if (!bridge) { + return Promise.resolve(null); + } + + desktopBearerTokenPromise ??= bridge.getLocalEnvironmentBearerToken().catch((error) => { + desktopBearerTokenPromise = null; + throw error; + }); + return desktopBearerTokenPromise; +} + +export function __resetDesktopPrimaryAuthForTests(): void { + desktopBearerTokenPromise = null; +} diff --git a/apps/web/src/environments/primary/httpClient.ts b/apps/web/src/environments/primary/httpClient.ts index d9404b4b8d3..a7860ec610c 100644 --- a/apps/web/src/environments/primary/httpClient.ts +++ b/apps/web/src/environments/primary/httpClient.ts @@ -1,20 +1,17 @@ -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { resolvePrimaryEnvironmentHttpUrl } from "./target"; -export type PrimaryEnvironmentHttpClientShape = Effect.Success< - ReturnType ->; - export class PrimaryEnvironmentHttpClient extends Context.Service< PrimaryEnvironmentHttpClient, - PrimaryEnvironmentHttpClientShape + Effect.Success> >()("@t3tools/web/environments/primary/httpClient/PrimaryEnvironmentHttpClient") {} -export const primaryEnvironmentHttpClientLive = Layer.effect( - PrimaryEnvironmentHttpClient, - Effect.suspend(() => makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"))), +const make = Effect.suspend(() => + makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")), ); + +export const layer = Layer.effect(PrimaryEnvironmentHttpClient, make); diff --git a/apps/web/src/environments/primary/httpLayer.test.ts b/apps/web/src/environments/primary/httpLayer.test.ts new file mode 100644 index 00000000000..5bc1ef01da1 --- /dev/null +++ b/apps/web/src/environments/primary/httpLayer.test.ts @@ -0,0 +1,65 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient } from "effect/unstable/http"; + +import { __resetDesktopPrimaryAuthForTests } from "./desktopAuth"; +import { makePrimaryEnvironmentHttpLayer } from "./httpLayer"; + +describe.sequential("primary environment HTTP layer", () => { + afterEach(() => { + __resetDesktopPrimaryAuthForTests(); + Reflect.deleteProperty(globalThis, "window"); + vi.unstubAllGlobals(); + }); + + it.effect("uses cookie credentials for browser primary environments", () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + href: "http://127.0.0.1:3773/settings", + origin: "http://127.0.0.1:3773", + }, + }, + }); + + return Effect.gen(function* () { + yield* HttpClient.get("http://127.0.0.1:3773/api/auth/session"); + + const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + }).pipe(Effect.provide(makePrimaryEnvironmentHttpLayer())); + }); + + it.effect("uses bearer auth without cookies for desktop-managed primaries", () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { origin: "t3code://app" }, + desktopBridge: { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + getLocalEnvironmentBearerToken: vi.fn().mockResolvedValue("desktop-bearer-token"), + } as unknown as DesktopBridge, + }, + }); + + return Effect.gen(function* () { + yield* HttpClient.get("http://127.0.0.1:3773/api/connect/link-state"); + + const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); + expect(request.credentials).not.toBe("include"); + expect(request.headers.get("authorization")).toBe("Bearer desktop-bearer-token"); + }).pipe(Effect.provide(makePrimaryEnvironmentHttpLayer())); + }); +}); diff --git a/apps/web/src/environments/primary/httpLayer.ts b/apps/web/src/environments/primary/httpLayer.ts new file mode 100644 index 00000000000..bedb4954d54 --- /dev/null +++ b/apps/web/src/environments/primary/httpLayer.ts @@ -0,0 +1,58 @@ +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { readDesktopPrimaryBearerToken } from "./desktopAuth"; +import { resolvePrimaryEnvironmentHttpUrl } from "./target"; + +function isSameOriginBrowserPrimary(): boolean { + if ( + typeof window === "undefined" || + window.desktopBridge !== undefined || + window.nativeApi !== undefined || + !window.location.origin.startsWith("http") + ) { + return false; + } + + return new URL(resolvePrimaryEnvironmentHttpUrl("/")).origin === window.location.origin; +} + +function withPrimaryBearerToken(client: HttpClient.HttpClient): HttpClient.HttpClient { + return client.pipe( + HttpClient.mapRequestEffect((request) => + Effect.promise(readDesktopPrimaryBearerToken).pipe( + Effect.map((bearerToken) => + bearerToken ? HttpClientRequest.bearerToken(request, bearerToken) : request, + ), + ), + ), + ); +} + +export function makePrimaryEnvironmentHttpLayer() { + return Layer.unwrap( + Effect.sync(() => { + const baseLayer = remoteHttpClientLayer(globalThis.fetch); + if (isSameOriginBrowserPrimary()) { + return Layer.merge( + baseLayer, + Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }), + ); + } + + const bearerClientLayer = Layer.effect( + HttpClient.HttpClient, + Effect.map(HttpClient.HttpClient, withPrimaryBearerToken), + ).pipe(Layer.provide(baseLayer)); + + return Layer.merge( + bearerClientLayer, + Layer.succeed(FetchHttpClient.RequestInit, { credentials: "omit" }), + ); + }), + ); +} + +export const primaryEnvironmentHttpLayer = makePrimaryEnvironmentHttpLayer(); diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 09576c34e42..58342d53054 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -3,7 +3,6 @@ export { readPrimaryEnvironmentDescriptor, resetPrimaryEnvironmentDescriptorForTests, resolveInitialPrimaryEnvironmentDescriptor, - usePrimaryEnvironmentId, writePrimaryEnvironmentDescriptor, __resetPrimaryEnvironmentBootstrapForTests, __resetPrimaryEnvironmentDescriptorBootstrapForTests, @@ -17,9 +16,13 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentPairingCredentialRejectedError, + isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentPairingCredentialRejectedError, + PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, revokeServerClientSession, @@ -34,8 +37,17 @@ export { export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; +export { PrimaryEnvironmentHttpClient } from "./httpClient"; + export { + DesktopEnvironmentBootstrapIncompleteError, + isDesktopEnvironmentBootstrapIncompleteError, + isPrimaryEnvironmentProtocolUnsupportedError, + isPrimaryEnvironmentUrlInvalidError, + PrimaryEnvironmentProtocolUnsupportedError, + PrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname, + type PrimaryEnvironmentTarget, } from "./target"; diff --git a/apps/web/src/environments/primary/requestInit.ts b/apps/web/src/environments/primary/requestInit.ts deleted file mode 100644 index cf70237380b..00000000000 --- a/apps/web/src/environments/primary/requestInit.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as Effect from "effect/Effect"; -import { FetchHttpClient } from "effect/unstable/http"; - -export const primaryEnvironmentRequestInit = { credentials: "include" } as const; - -export const withPrimaryEnvironmentRequestInit = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit)); diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index ad375ba7e25..5ec3afa9fa1 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,13 +1,80 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; -import type { KnownEnvironment } from "@t3tools/client-runtime"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { normalizeBasePath } from "@t3tools/shared/basePath"; import { BASE_PATH } from "../../basePath"; +const PrimaryEnvironmentTargetSource = Schema.Literals([ + "configured", + "window-origin", + "desktop-managed", +]); +type PrimaryEnvironmentTargetSource = typeof PrimaryEnvironmentTargetSource.Type; + +const PrimaryEnvironmentUrlKind = Schema.Literals([ + "http-base-url", + "websocket-base-url", + "development-server-url", + "window-location-url", +]); +type PrimaryEnvironmentUrlKind = typeof PrimaryEnvironmentUrlKind.Type; + +export class PrimaryEnvironmentUrlInvalidError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentUrlInvalidError", + { + source: PrimaryEnvironmentTargetSource, + urlKind: PrimaryEnvironmentUrlKind, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not parse ${this.urlKind} for the ${this.source} primary environment target.`; + } +} + +export class PrimaryEnvironmentProtocolUnsupportedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentProtocolUnsupportedError", + { + source: PrimaryEnvironmentTargetSource, + protocol: Schema.String, + }, +) { + override get message(): string { + return `The ${this.source} primary environment target uses unsupported protocol ${this.protocol}.`; + } +} + +export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErrorClass()( + "DesktopEnvironmentBootstrapIncompleteError", + { + hasHttpBaseUrl: Schema.Boolean, + hasWsBaseUrl: Schema.Boolean, + }, +) { + override get message(): string { + const missing = [ + ...(this.hasHttpBaseUrl ? [] : ["httpBaseUrl"]), + ...(this.hasWsBaseUrl ? [] : ["wsBaseUrl"]), + ]; + return `Desktop bootstrap is missing ${missing.join(" and ")} for the local environment.`; + } +} + +export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); +export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( + PrimaryEnvironmentProtocolUnsupportedError, +); +export const isDesktopEnvironmentBootstrapIncompleteError = Schema.is( + DesktopEnvironmentBootstrapIncompleteError, +); + export interface PrimaryEnvironmentTarget { - readonly source: KnownEnvironment["source"]; - readonly target: KnownEnvironment["target"]; + readonly source: PrimaryEnvironmentTargetSource; + readonly target: { + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + }; } const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); @@ -16,15 +83,49 @@ function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | nu return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; } -function normalizeBaseUrl(rawValue: string): string { - return new URL(rawValue, window.location.origin).toString(); +function parseTargetUrl(input: { + readonly rawValue: string; + readonly baseUrl?: string; + readonly source: PrimaryEnvironmentTargetSource; + readonly urlKind: PrimaryEnvironmentUrlKind; +}): URL { + try { + return input.baseUrl === undefined + ? new URL(input.rawValue) + : new URL(input.rawValue, input.baseUrl); + } catch (cause) { + throw new PrimaryEnvironmentUrlInvalidError({ + source: input.source, + urlKind: input.urlKind, + cause, + }); + } +} + +function normalizeBaseUrl( + rawValue: string, + source: PrimaryEnvironmentTargetSource, + urlKind: PrimaryEnvironmentUrlKind, +): string { + return parseTargetUrl({ + rawValue, + baseUrl: window.location.origin, + source, + urlKind, + }).toString(); } function swapBaseUrlProtocol( rawValue: string, nextProtocol: "http:" | "https:" | "ws:" | "wss:", + urlKind: PrimaryEnvironmentUrlKind, ): string { - const url = new URL(normalizeBaseUrl(rawValue)); + const url = parseTargetUrl({ + rawValue, + baseUrl: window.location.origin, + source: "configured", + urlKind, + }); url.protocol = nextProtocol; return url.toString(); } @@ -40,15 +141,29 @@ export function isLoopbackHostname(hostname: string): boolean { return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); } -function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { +function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string { + const httpBaseUrl = primaryTarget.target.httpBaseUrl; const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); if (!configuredDevServerUrl) { return httpBaseUrl; } - const currentUrl = new URL(window.location.href); - const targetUrl = new URL(httpBaseUrl); - const devServerUrl = new URL(configuredDevServerUrl, currentUrl.origin); + const currentUrl = parseTargetUrl({ + rawValue: window.location.href, + source: "window-origin", + urlKind: "window-location-url", + }); + const targetUrl = parseTargetUrl({ + rawValue: httpBaseUrl, + source: primaryTarget.source, + urlKind: "http-base-url", + }); + const devServerUrl = parseTargetUrl({ + rawValue: configuredDevServerUrl, + baseUrl: currentUrl.origin, + source: "configured", + urlKind: "development-server-url", + }); const isCurrentOriginDevServer = (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && @@ -77,32 +192,39 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { const resolvedHttpBaseUrl = configuredHttpBaseUrl ?? (configuredWsBaseUrl?.startsWith("wss:") - ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:") - : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:")); + ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url") + : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url")); const resolvedWsBaseUrl = configuredWsBaseUrl ?? (configuredHttpBaseUrl?.startsWith("https:") - ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:") - : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:")); + ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:", "http-base-url") + : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:", "http-base-url")); return { source: "configured", target: { - httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), - wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl), + httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), + wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), }, }; } function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { - const httpBaseUrl = `${window.location.origin}${BASE_PATH}/`; - const url = new URL(httpBaseUrl); + const url = parseTargetUrl({ + rawValue: `${window.location.origin}${BASE_PATH}/`, + source: "window-origin", + urlKind: "http-base-url", + }); + const httpBaseUrl = url.toString(); if (url.protocol === "http:") { url.protocol = "ws:"; } else if (url.protocol === "https:") { url.protocol = "wss:"; } else { - throw new Error(`Unsupported HTTP base URL protocol: ${url.protocol}`); + throw new PrimaryEnvironmentProtocolUnsupportedError({ + source: "window-origin", + protocol: url.protocol, + }); } return { source: "window-origin", @@ -122,16 +244,25 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { return null; } if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) { - throw new Error( - "Desktop bootstrap must provide both httpBaseUrl and wsBaseUrl for the local environment.", - ); + throw new DesktopEnvironmentBootstrapIncompleteError({ + hasHttpBaseUrl: Boolean(desktopBootstrap.httpBaseUrl), + hasWsBaseUrl: Boolean(desktopBootstrap.wsBaseUrl), + }); } return { source: "desktop-managed", target: { - httpBaseUrl: normalizeBaseUrl(desktopBootstrap.httpBaseUrl), - wsBaseUrl: normalizeBaseUrl(desktopBootstrap.wsBaseUrl), + httpBaseUrl: normalizeBaseUrl( + desktopBootstrap.httpBaseUrl, + "desktop-managed", + "http-base-url", + ), + wsBaseUrl: normalizeBaseUrl( + desktopBootstrap.wsBaseUrl, + "desktop-managed", + "websocket-base-url", + ), }, }; } @@ -141,11 +272,12 @@ export function resolvePrimaryEnvironmentHttpUrl( searchParams?: Record, ): string { const primaryTarget = readPrimaryEnvironmentTarget(); - if (!primaryTarget) { - throw new Error("Unable to resolve the primary environment HTTP base URL."); - } - const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl)); + const url = parseTargetUrl({ + rawValue: resolveHttpRequestBaseUrl(primaryTarget), + source: primaryTarget.source, + urlKind: "http-base-url", + }); url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}${pathname}`; url.search = ""; url.hash = ""; @@ -155,7 +287,7 @@ export function resolvePrimaryEnvironmentHttpUrl( return url.toString(); } -export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { +export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { return ( resolveDesktopPrimaryTarget() ?? resolveConfiguredPrimaryTarget() ?? diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts deleted file mode 100644 index f6c5fc85277..00000000000 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - EnvironmentId, - type LocalApi, - type PersistedSavedEnvironmentRecord, -} from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - readSavedEnvironmentCredential, - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - waitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentCredential, -} from "./catalog"; - -let resolveRegistryRead: () => void = () => { - throw new Error("Registry read resolver was not initialized."); -}; - -describe("environment runtime catalog stores", () => { - beforeEach(async () => { - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - }); - - afterEach(async () => { - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - vi.unstubAllGlobals(); - }); - - it("resets the saved environment registry store state", () => { - const environmentId = EnvironmentId.make("environment-1"); - - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId, - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }); - - expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toBeDefined(); - - resetSavedEnvironmentRegistryStoreForTests(); - - expect(useSavedEnvironmentRegistryStore.getState().byId).toEqual({}); - }); - - it("resets the saved environment runtime store state", () => { - const environmentId = EnvironmentId.make("environment-1"); - - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connected", - connectedAt: "2026-04-09T00:00:00.000Z", - }); - - expect(useSavedEnvironmentRuntimeStore.getState().byId[environmentId]).toBeDefined(); - - resetSavedEnvironmentRuntimeStoreForTests(); - - expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); - }); - - it("decodes legacy bearer secrets and writes versioned DPoP credentials", async () => { - let storedSecret: string | null = "legacy-bearer-token"; - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => storedSecret, - setSavedEnvironmentSecret: async (_environmentId, secret) => { - storedSecret = secret; - return true; - }, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - const environmentId = EnvironmentId.make("environment-1"); - - await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ - version: 1, - method: "bearer", - token: "legacy-bearer-token", - }); - await expect( - writeSavedEnvironmentCredential(environmentId, { - version: 1, - method: "dpop", - accessToken: "managed-dpop-access-token", - }), - ).resolves.toBe(true); - await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ - version: 1, - method: "dpop", - accessToken: "managed-dpop-access-token", - }); - }); - - it("does not throw when local api lookup fails during registry persistence", async () => { - vi.unstubAllGlobals(); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - - expect(() => - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }), - ).not.toThrow(); - - expect(errorSpy).toHaveBeenCalledWith("[SAVED_ENVIRONMENTS] persist failed", expect.any(Error)); - }); - - it("does not let stale hydration overwrite records added while hydration is in flight", async () => { - resolveRegistryRead = () => { - throw new Error("Registry read resolver was not initialized."); - }; - - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: () => - new Promise((resolve) => { - resolveRegistryRead = () => resolve([]); - }), - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - - const hydrationPromise = waitForSavedEnvironmentRegistryHydration(); - - const environmentId = EnvironmentId.make("environment-1"); - const record = { - environmentId, - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - } as const; - - useSavedEnvironmentRegistryStore.getState().upsert(record); - - resolveRegistryRead(); - await hydrationPromise; - - expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toEqual(record); - }); -}); diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts deleted file mode 100644 index 9593d5c5022..00000000000 --- a/apps/web/src/environments/runtime/catalog.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; -import * as Effect from "effect/Effect"; -import { normalizeBasePath } from "@t3tools/shared/basePath"; -import type { - AuthEnvironmentScope, - EnvironmentId, - ExecutionEnvironmentDescriptor, - PersistedSavedEnvironmentRecord, - ServerConfig, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { create } from "zustand"; - -import { ensureLocalApi } from "../../localApi"; -import { getPrimaryKnownEnvironment } from "../primary"; - -export interface SavedEnvironmentRecord { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly createdAt: string; - readonly lastConnectedAt: string | null; - readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; - readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"]; -} - -export const SavedEnvironmentCredential = Schema.Union([ - Schema.Struct({ - version: Schema.Literal(1), - method: Schema.Literal("bearer"), - token: Schema.String, - }), - Schema.Struct({ - version: Schema.Literal(1), - method: Schema.Literal("dpop"), - accessToken: Schema.String, - }), -]); -export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type; - -const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential); -const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption( - SavedEnvironmentCredentialJson, -); -const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson); - -interface SavedEnvironmentRegistryState { - readonly byId: Record; -} - -interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { - readonly upsert: (record: SavedEnvironmentRecord) => void; - readonly remove: (environmentId: EnvironmentId) => void; - readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void; - readonly rename: (environmentId: EnvironmentId, label: string) => void; - readonly reset: () => void; -} - -let savedEnvironmentRegistryHydrated = false; -let savedEnvironmentRegistryHydrationPromise: Promise | null = null; - -export function toPersistedSavedEnvironmentRecord( - record: SavedEnvironmentRecord, -): PersistedSavedEnvironmentRecord { - return { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - }; -} - -function valuesOfSavedEnvironmentRegistry( - byId: Record, -): ReadonlyArray { - return Object.values(byId) as ReadonlyArray; -} - -function persistSavedEnvironmentRegistryState( - byId: Record, -): void { - try { - void ensureLocalApi() - .persistence.setSavedEnvironmentRegistry( - valuesOfSavedEnvironmentRegistry(byId).map((record) => - toPersistedSavedEnvironmentRecord(record), - ), - ) - .catch((error) => { - console.error("[SAVED_ENVIRONMENTS] persist failed", error); - }); - } catch (error) { - console.error("[SAVED_ENVIRONMENTS] persist failed", error); - } -} - -function replaceSavedEnvironmentRegistryState( - records: ReadonlyArray, -): void { - const currentById = useSavedEnvironmentRegistryStore.getState().byId; - const hydratedById = Object.fromEntries(records.map((record) => [record.environmentId, record])); - useSavedEnvironmentRegistryStore.setState({ - byId: { - ...hydratedById, - ...currentById, - }, - }); -} - -async function hydrateSavedEnvironmentRegistry(): Promise { - if (savedEnvironmentRegistryHydrated) { - return; - } - if (savedEnvironmentRegistryHydrationPromise) { - return savedEnvironmentRegistryHydrationPromise; - } - - const nextHydration = (async () => { - try { - const persistedRecords = await ensureLocalApi().persistence.getSavedEnvironmentRegistry(); - replaceSavedEnvironmentRegistryState(persistedRecords); - } catch (error) { - console.error("[SAVED_ENVIRONMENTS] hydrate failed", error); - } finally { - savedEnvironmentRegistryHydrated = true; - } - })(); - - const hydrationPromise = nextHydration.finally(() => { - if (savedEnvironmentRegistryHydrationPromise === hydrationPromise) { - savedEnvironmentRegistryHydrationPromise = null; - } - }); - savedEnvironmentRegistryHydrationPromise = hydrationPromise; - - return savedEnvironmentRegistryHydrationPromise; -} - -export const useSavedEnvironmentRegistryStore = create()((set) => ({ - byId: {}, - upsert: (record) => - set((state) => { - const byId = { - ...state.byId, - [record.environmentId]: record, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - remove: (environmentId) => - set((state) => { - const { [environmentId]: _removed, ...remaining } = state.byId; - persistSavedEnvironmentRegistryState(remaining); - return { - byId: remaining, - }; - }), - markConnected: (environmentId, connectedAt) => - set((state) => { - const existing = state.byId[environmentId]; - if (!existing) { - return state; - } - const byId = { - ...state.byId, - [environmentId]: { - ...existing, - lastConnectedAt: connectedAt, - }, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - rename: (environmentId, label) => - set((state) => { - const existing = state.byId[environmentId]; - const nextLabel = label.trim(); - if (!existing || nextLabel.length === 0 || existing.label === nextLabel) { - return state; - } - const byId = { - ...state.byId, - [environmentId]: { - ...existing, - label: nextLabel, - }, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - reset: () => { - persistSavedEnvironmentRegistryState({}); - set({ - byId: {}, - }); - }, -})); - -export function hasSavedEnvironmentRegistryHydrated(): boolean { - return savedEnvironmentRegistryHydrated; -} - -export function waitForSavedEnvironmentRegistryHydration(): Promise { - if (hasSavedEnvironmentRegistryHydrated()) { - return Promise.resolve(); - } - - return hydrateSavedEnvironmentRegistry(); -} - -export function listSavedEnvironmentRecords(): ReadonlyArray { - return Object.values(useSavedEnvironmentRegistryStore.getState().byId).toSorted((left, right) => - left.label.localeCompare(right.label), - ); -} - -export function getSavedEnvironmentRecord( - environmentId: EnvironmentId, -): SavedEnvironmentRecord | null { - return useSavedEnvironmentRegistryStore.getState().byId[environmentId] ?? null; -} - -export function getEnvironmentHttpBaseUrl(environmentId: EnvironmentId): string | null { - const primaryEnvironment = getPrimaryKnownEnvironment(); - if (primaryEnvironment?.environmentId === environmentId) { - return getKnownEnvironmentHttpBaseUrl(primaryEnvironment); - } - - return getSavedEnvironmentRecord(environmentId)?.httpBaseUrl ?? null; -} - -export function resolveEnvironmentHttpUrl(input: { - readonly environmentId: EnvironmentId; - readonly pathname: string; - readonly searchParams?: Record; -}): string { - const httpBaseUrl = getEnvironmentHttpBaseUrl(input.environmentId); - if (!httpBaseUrl) { - throw new Error(`Unable to resolve HTTP base URL for environment ${input.environmentId}.`); - } - - const url = new URL(httpBaseUrl); - url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}${input.pathname}`; - url.search = ""; - url.hash = ""; - if (input.searchParams) { - url.search = new URLSearchParams(input.searchParams).toString(); - } - return url.toString(); -} - -export function resetSavedEnvironmentRegistryStoreForTests() { - savedEnvironmentRegistryHydrated = false; - savedEnvironmentRegistryHydrationPromise = null; - useSavedEnvironmentRegistryStore.setState({ byId: {} }); -} - -export async function persistSavedEnvironmentRecord(record: SavedEnvironmentRecord): Promise { - const byId = { - ...useSavedEnvironmentRegistryStore.getState().byId, - [record.environmentId]: record, - }; - - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - valuesOfSavedEnvironmentRegistry(byId).map((entry) => toPersistedSavedEnvironmentRecord(entry)), - ); -} - -export async function readSavedEnvironmentBearerToken( - environmentId: EnvironmentId, -): Promise { - return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); -} - -export async function readSavedEnvironmentCredential( - environmentId: EnvironmentId, -): Promise { - const secret = await ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); - if (!secret) { - return null; - } - const decoded = decodeSavedEnvironmentCredentialJson(secret); - if (Option.isSome(decoded)) { - return decoded.value; - } - // Legacy bearer secrets were stored directly as strings. - return { version: 1, method: "bearer", token: secret }; -} - -export async function writeSavedEnvironmentCredential( - environmentId: EnvironmentId, - credential: SavedEnvironmentCredential, -): Promise { - return ensureLocalApi().persistence.setSavedEnvironmentSecret( - environmentId, - encodeSavedEnvironmentCredentialJson(credential), - ); -} - -export async function writeSavedEnvironmentBearerToken( - environmentId: EnvironmentId, - bearerToken: string, -): Promise { - return ensureLocalApi().persistence.setSavedEnvironmentSecret(environmentId, bearerToken); -} - -export async function removeSavedEnvironmentBearerToken( - environmentId: EnvironmentId, -): Promise { - await ensureLocalApi().persistence.removeSavedEnvironmentSecret(environmentId); -} - -export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error"; - -export type SavedEnvironmentAuthState = "authenticated" | "requires-auth" | "unknown"; - -export interface SavedEnvironmentRuntimeState { - readonly connectionState: SavedEnvironmentConnectionState; - readonly authState: SavedEnvironmentAuthState; - readonly lastError: string | null; - readonly lastErrorAt: string | null; - readonly scopes: ReadonlyArray | null; - readonly descriptor: ExecutionEnvironmentDescriptor | null; - readonly serverConfig: ServerConfig | null; - readonly connectedAt: string | null; - readonly disconnectedAt: string | null; -} - -interface SavedEnvironmentRuntimeStoreState { - readonly byId: Record; - readonly ensure: (environmentId: EnvironmentId) => void; - readonly patch: ( - environmentId: EnvironmentId, - patch: Partial, - ) => void; - readonly clear: (environmentId: EnvironmentId) => void; - readonly reset: () => void; -} - -const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Object.freeze({ - connectionState: "disconnected", - authState: "unknown", - lastError: null, - lastErrorAt: null, - scopes: null, - descriptor: null, - serverConfig: null, - connectedAt: null, - disconnectedAt: null, -}); - -function createDefaultSavedEnvironmentRuntimeState(): SavedEnvironmentRuntimeState { - return { - ...DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE, - }; -} - -export const useSavedEnvironmentRuntimeStore = create()( - (set) => ({ - byId: {}, - ensure: (environmentId) => - set((state) => { - if (state.byId[environmentId]) { - return state; - } - return { - byId: { - ...state.byId, - [environmentId]: createDefaultSavedEnvironmentRuntimeState(), - }, - }; - }), - patch: (environmentId, patch) => - set((state) => ({ - byId: { - ...state.byId, - [environmentId]: { - ...(state.byId[environmentId] ?? createDefaultSavedEnvironmentRuntimeState()), - ...patch, - }, - }, - })), - clear: (environmentId) => - set((state) => { - const { [environmentId]: _removed, ...remaining } = state.byId; - return { - byId: remaining, - }; - }), - reset: () => - set({ - byId: {}, - }), - }), -); - -export function getSavedEnvironmentRuntimeState( - environmentId: EnvironmentId, -): SavedEnvironmentRuntimeState { - return ( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId] ?? - DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE - ); -} - -export function resetSavedEnvironmentRuntimeStoreForTests() { - useSavedEnvironmentRuntimeStore.getState().reset(); -} diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts deleted file mode 100644 index 392db299339..00000000000 --- a/apps/web/src/environments/runtime/connection.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { createEnvironmentConnection } from "./connection"; -import type { WsRpcClient } from "@t3tools/client-runtime"; - -function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) { - const lifecycleListeners = new Set<(event: any) => void>(); - const configListeners = new Set<(event: any) => void>(); - const shellListeners = new Set<(event: any) => void>(); - let shellResubscribe: (() => void) | undefined; - - const client = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => { - shellResubscribe?.(); - }), - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-1"), - }, - })), - subscribeConfig: vi.fn((listener: (event: any) => void) => { - configListeners.add(listener); - return () => configListeners.delete(listener); - }), - subscribeLifecycle: vi.fn((listener: (event: any) => void) => { - lifecycleListeners.add(listener); - return () => lifecycleListeners.delete(listener); - }), - subscribeAuthAccess: () => () => undefined, - refreshProviders: vi.fn(async () => undefined), - upsertKeybinding: vi.fn(async () => undefined), - getSettings: vi.fn(async () => undefined), - updateSettings: vi.fn(async () => undefined), - }, - orchestration: { - dispatchCommand: vi.fn(async () => undefined), - getTurnDiff: vi.fn(async () => undefined), - getFullThreadDiff: vi.fn(async () => undefined), - subscribeShell: vi.fn( - (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { - shellListeners.add(listener); - shellResubscribe = options?.onResubscribe; - if (config?.emitInitialSnapshot !== false) { - queueMicrotask(() => { - listener({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - projects: [], - threads: [], - updatedAt: "2026-04-12T00:00:00.000Z", - }, - }); - }); - } - return () => { - shellListeners.delete(listener); - if (shellResubscribe === options?.onResubscribe) { - shellResubscribe = undefined; - } - }; - }, - ), - subscribeThread: vi.fn(() => () => undefined), - }, - terminal: { - open: vi.fn(async () => undefined), - attach: vi.fn(() => () => undefined), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - clear: vi.fn(async () => undefined), - restart: vi.fn(async () => undefined), - close: vi.fn(async () => undefined), - onEvent: vi.fn(() => () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - projects: { - searchEntries: vi.fn(async () => []), - writeFile: vi.fn(async () => undefined), - }, - shell: { - openInEditor: vi.fn(async () => undefined), - }, - git: { - runStackedAction: vi.fn(async () => ({}) as any), - resolvePullRequest: vi.fn(async () => undefined), - preparePullRequestThread: vi.fn(async () => undefined), - }, - review: { - getDiffPreview: vi.fn(async () => undefined), - }, - } as unknown as WsRpcClient; - - return { - client, - emitWelcome: (environmentId: EnvironmentId) => { - for (const listener of lifecycleListeners) { - listener({ - type: "welcome", - payload: { - environment: { - environmentId, - }, - }, - }); - } - }, - emitConfigSnapshot: (environmentId: EnvironmentId) => { - for (const listener of configListeners) { - listener({ - type: "snapshot", - config: { - environment: { - environmentId, - }, - }, - }); - } - }, - emitShellSnapshot: (snapshotSequence: number) => { - for (const listener of shellListeners) { - listener({ - kind: "snapshot", - snapshot: { - snapshotSequence, - projects: [], - threads: [], - updatedAt: "2026-04-12T00:00:00.000Z", - }, - }); - } - }, - }; -} - -describe("createEnvironmentConnection", () => { - it("bootstraps from the shell subscription snapshot", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient(); - const syncShellSnapshot = vi.fn(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot, - }); - - await connection.ensureBootstrapped(); - - expect(syncShellSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ snapshotSequence: 1 }), - environmentId, - ); - - await connection.dispose(); - }); - - it("rejects welcome/config identity drift", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client, emitWelcome } = createTestClient(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - }); - - expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow( - "Environment connection env-1 changed identity to env-2 via server lifecycle welcome.", - ); - - await connection.dispose(); - }); - - it("waits for a fresh shell snapshot after reconnect", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client, emitShellSnapshot } = createTestClient(); - const syncShellSnapshot = vi.fn(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot, - }); - - await connection.ensureBootstrapped(); - - const reconnectPromise = connection.reconnect(); - await Promise.resolve(); - expect(syncShellSnapshot).toHaveBeenCalledTimes(1); - - emitShellSnapshot(2); - await reconnectPromise; - - expect(client.reconnect).toHaveBeenCalledTimes(1); - expect(syncShellSnapshot).toHaveBeenCalledTimes(2); - expect(syncShellSnapshot).toHaveBeenLastCalledWith( - expect.objectContaining({ snapshotSequence: 2 }), - environmentId, - ); - - await connection.dispose(); - }); - - it("skips primary lifecycle/config subscriptions when no handlers are registered", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient(); - - const connection = createEnvironmentConnection({ - kind: "primary", - knownEnvironment: { - id: "env-1", - label: "Local env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), - }); - - expect(client.server.subscribeLifecycle).not.toHaveBeenCalled(); - expect(client.server.subscribeConfig).not.toHaveBeenCalled(); - expect(client.orchestration.subscribeShell).toHaveBeenCalledOnce(); - - await connection.dispose(); - }); - - it("rejects bootstrap waits when a pending connection is disposed", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient({ emitInitialSnapshot: false }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - }); - const pendingBootstrap = connection.ensureBootstrapped(); - - await connection.dispose(); - - await expect(pendingBootstrap).rejects.toThrow("was disposed before it finished bootstrapping"); - }); -}); diff --git a/apps/web/src/environments/runtime/connection.ts b/apps/web/src/environments/runtime/connection.ts deleted file mode 100644 index cb1c606b435..00000000000 --- a/apps/web/src/environments/runtime/connection.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - EnvironmentConnectionAttemptCancelledError, - EnvironmentConnectionDisposedError, - type EnvironmentConnection, -} from "@t3tools/client-runtime"; diff --git a/apps/web/src/environments/runtime/index.ts b/apps/web/src/environments/runtime/index.ts deleted file mode 100644 index 7333e03a42a..00000000000 --- a/apps/web/src/environments/runtime/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export { - getEnvironmentHttpBaseUrl, - getSavedEnvironmentRecord, - getSavedEnvironmentRuntimeState, - hasSavedEnvironmentRegistryHydrated, - listSavedEnvironmentRecords, - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, - resolveEnvironmentHttpUrl, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - waitForSavedEnvironmentRegistryHydration, - type SavedEnvironmentRecord, - type SavedEnvironmentRuntimeState, -} from "./catalog"; - -export { - addSavedEnvironment, - addManagedRelayEnvironment, - connectDesktopSshEnvironment, - disconnectSavedEnvironment, - ensureEnvironmentConnectionBootstrapped, - getPrimaryEnvironmentConnection, - readEnvironmentConnection, - reconnectSavedEnvironment, - removeSavedEnvironment, - requireEnvironmentConnection, - resetEnvironmentServiceForTests, - startEnvironmentConnectionService, - subscribeEnvironmentConnections, - subscribeProviderInvalidations, -} from "./service"; diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts deleted file mode 100644 index a9da256f8fe..00000000000 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ /dev/null @@ -1,1111 +0,0 @@ -import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Tracer from "effect/Tracer"; -import { Headers } from "effect/unstable/http"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; - -const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError); - -let mockSavedRecords: Array> = []; - -const mockResolveRemotePairingTarget = vi.fn(); -const mockFetchRemoteEnvironmentDescriptor = vi.fn(); -const mockBootstrapRemoteBearerSession = vi.fn(); -const mockFetchRemoteSessionState = vi.fn(); -const mockFetchRemoteDpopSessionState = vi.fn(); -const mockResolveRemoteDpopWebSocketConnectionUrl = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); -const mockWsTransportConnectors: Array<() => Promise> = []; -let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner; -let mockRelayClientTracer = Option.none(); -const mockRemoteHttpRunPromise = vi.fn((effect: Effect.Effect) => - Effect.runPromise( - effect.pipe( - Effect.provideService( - managedRelayDpopSigner, - managedRelayDpopSigner.of({ - thumbprint: Effect.succeed("thumbprint"), - createProof: () => Effect.succeed("dpop-proof"), - }), - ), - Effect.provideService(RelayClientTracer, mockRelayClientTracer), - ), - ), -); -const mockBootstrapSshBearerSession = vi.fn(); -const mockFetchSshSessionState = vi.fn(); -const mockPersistSavedEnvironmentRecord = vi.fn(); -const mockWriteSavedEnvironmentBearerToken = vi.fn(); -const mockWriteSavedEnvironmentCredential = vi.fn(); -const mockSetSavedEnvironmentRegistry = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { - return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; -}); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockRemoveSavedEnvironmentBearerToken = vi.fn(); -const mockPatchRuntime = vi.fn(); -const mockClearRuntime = vi.fn(); -const mockRegistrySetState = vi.fn((next: { byId: Record> }) => { - mockSavedRecords = Object.values(next.byId); -}); -const mockRemove = vi.fn((environmentId: EnvironmentId) => { - mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId); -}); -const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => { - mockSavedRecords = mockSavedRecords.map((record) => - record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record, - ); -}); -const mockRename = vi.fn((environmentId: EnvironmentId, label: string) => { - mockSavedRecords = mockSavedRecords.map((record) => - record.environmentId === environmentId ? { ...record, label } : record, - ); -}); -const mockUpsert = vi.fn((record: Record) => { - mockSavedRecords = [ - ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId), - record, - ]; -}); -const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords); -const mockEnsureSshEnvironment = vi.fn(); -const mockDisconnectSshEnvironment = vi.fn(); -const mockFetchSshEnvironmentDescriptor = vi.fn(); -const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record); -const mockCreateEnvironmentConnection = vi.fn(); -const mockClientGetConfig = vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }, -})); -const mockConnectManagedCloudEnvironment = vi.fn(); -const mockReadManagedRelayClerkToken = vi.fn(); - -vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ - ...(await importOriginal()), - resolveRemotePairingTarget: mockResolveRemotePairingTarget, -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("../../cloud/linkEnvironment", () => ({ - connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment, -})); - -vi.mock("../../cloud/managedAuth", () => ({ - readManagedRelayClerkToken: mockReadManagedRelayClerkToken, -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: () => ({ - persistence: { - setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry, - }, - }), -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, - toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, - useSavedEnvironmentRegistryStore: { - getState: () => ({ - upsert: mockUpsert, - remove: mockRemove, - markConnected: mockMarkConnected, - rename: mockRename, - }), - setState: mockRegistrySetState, - subscribe: vi.fn(() => () => {}), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: mockPatchRuntime, - clear: mockClearRuntime, - }), - }, - waitForSavedEnvironmentRegistryHydration: vi.fn(), - writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, - writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential, -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - managedRelayDpopSigner = actual.ManagedRelayDpopSigner; - return { - ...actual, - bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, - createWsRpcClient: vi.fn(() => ({ - server: { - getConfig: mockClientGetConfig, - }, - terminal: { - onMetadata: vi.fn(() => () => undefined), - }, - orchestration: { - subscribeThread: vi.fn(() => () => {}), - }, - preview: { - subscribePorts: vi.fn(() => () => undefined), - }, - })), - fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, - fetchRemoteSessionState: mockFetchRemoteSessionState, - fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState, - resolveRemoteDpopWebSocketConnectionUrl: mockResolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: vi.fn(function WsTransport(connect: () => Promise) { - mockWsTransportConnectors.push(connect); - return {}; - }), -})); - -describe("addSavedEnvironment", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - mockSavedRecords = []; - mockRelayClientTracer = Option.none(); - mockWsTransportConnectors.length = 0; - vi.stubGlobal("window", { - desktopBridge: { - ensureSshEnvironment: mockEnsureSshEnvironment, - disconnectSshEnvironment: mockDisconnectSshEnvironment, - fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, - bootstrapSshBearerSession: mockBootstrapSshBearerSession, - fetchSshSessionState: mockFetchSshSessionState, - issueSshWebSocketTicket: vi.fn(), - }, - }); - mockResolveRemotePairingTarget.mockImplementation( - (input: { host?: string; pairingCode?: string }) => ({ - httpBaseUrl: input.host - ? input.host.endsWith("/") - ? input.host - : `${input.host}/` - : "https://remote.example.com/", - wsBaseUrl: input.host - ? input.host.replace(/^http/u, "ws").endsWith("/") - ? input.host.replace(/^http/u, "ws") - : `${input.host.replace(/^http/u, "ws")}/` - : "wss://remote.example.com/", - credential: input.pairingCode ?? "pairing-code", - }), - ); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockFetchRemoteEnvironmentDescriptor.mockReturnValue( - Effect.succeed({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }), - ); - mockBootstrapRemoteBearerSession.mockReturnValue( - Effect.succeed({ - access_token: "bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }), - ); - mockFetchRemoteSessionState.mockReturnValue( - Effect.succeed({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }), - ); - mockFetchRemoteDpopSessionState.mockReturnValue( - Effect.succeed({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }), - ); - mockResolveRemoteWebSocketConnectionUrl.mockReturnValue( - Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"), - ); - mockResolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( - Effect.succeed("wss://remote.example.com/?wsTicket=remote-dpop-token"), - ); - mockFetchSshEnvironmentDescriptor.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }); - mockBootstrapSshBearerSession.mockResolvedValue({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); - mockWriteSavedEnvironmentCredential.mockResolvedValue(true); - mockReadManagedRelayClerkToken.mockResolvedValue(null); - mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); - mockFetchSshSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ - kind: "saved", - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }), - ); - mockClientGetConfig.mockResolvedValue({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }, - }); - mockEnsureSshEnvironment.mockResolvedValue({ - target: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - pairingToken: "ssh-pairing-code", - }); - mockDisconnectSshEnvironment.mockResolvedValue(undefined); - }); - - it("rolls back persisted metadata when bearer token persistence fails", async () => { - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Unable to persist saved environment credentials."); - - expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledTimes(1); - expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "bearer-token", - ); - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([]); - expect(mockUpsert).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("restores unrelated saved environments when credential persistence rollback runs", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-existing"), - label: "Existing environment", - httpBaseUrl: "https://existing.example.com/", - wsBaseUrl: "wss://existing.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Unable to persist saved environment credentials."); - - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([ - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-existing"), - }), - ]); - - await resetEnvironmentServiceForTests(); - }); - - it("persists the server label after saved environment metadata refresh", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockClientGetConfig.mockResolvedValue({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Julius's Mac mini", - }, - }); - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "100.65.180.100", - host: "remote.example.com", - pairingCode: "123456", - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockRename).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "Julius's Mac mini", - ); - expect(mockSavedRecords).toEqual([ - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-1"), - label: "Julius's Mac mini", - }), - ]); - - await resetEnvironmentServiceForTests(); - }); - - it("installs relay-managed environments with versioned DPoP credentials", async () => { - const { addManagedRelayEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await addManagedRelayEnvironment({ - environmentId: EnvironmentId.make("environment-1"), - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - relayUrl: "https://relay.example.com", - accessToken: "managed-access-token", - relayTraceHeaders: Headers.empty, - }); - - expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - { - version: 1, - method: "dpop", - accessToken: "managed-access-token", - }, - ); - expect(mockFetchRemoteDpopSessionState).toHaveBeenCalledWith({ - httpBaseUrl: "https://managed.example.com/", - accessToken: "managed-access-token", - dpopProof: "dpop-proof", - }); - await resetEnvironmentServiceForTests(); - }); - - it("renews expired managed DPoP credentials through the relay", async () => { - const environmentId = EnvironmentId.make("environment-1"); - const productSpans: Array = []; - mockRelayClientTracer = Option.some( - Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - productSpans.push(span); - return span; - }, - }), - ); - const relayTraceHeaders = Headers.fromInput({ - traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", - }); - mockSavedRecords = [ - { - environmentId, - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - createdAt: "2026-05-25T00:00:00.000Z", - lastConnectedAt: null, - relayManaged: { relayUrl: "https://relay.example.com" }, - }, - ]; - mockReadSavedEnvironmentCredential.mockResolvedValue({ - version: 1, - method: "dpop", - accessToken: "expired-access-token", - }); - mockFetchRemoteDpopSessionState - .mockReturnValueOnce( - Effect.fail( - decodeEnvironmentAuthInvalidError({ - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-auth-expired", - }), - ), - ) - .mockReturnValue(Effect.succeed({ authenticated: true, scopes: ["orchestration:read"] })); - mockReadManagedRelayClerkToken.mockResolvedValue("clerk-token"); - mockConnectManagedCloudEnvironment.mockReturnValue( - Effect.succeed({ - environmentId, - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - relayUrl: "https://relay.example.com", - accessToken: "renewed-access-token", - relayTraceHeaders, - }), - ); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - await reconnectSavedEnvironment(environmentId); - - expect(mockConnectManagedCloudEnvironment).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - relayUrl: "https://relay.example.com", - environment: expect.objectContaining({ environmentId }), - }); - expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(environmentId, { - version: 1, - method: "dpop", - accessToken: "renewed-access-token", - }); - const renewedTransportConnector = mockWsTransportConnectors.at(-1); - expect(renewedTransportConnector).toBeDefined(); - await renewedTransportConnector!(); - expect(mockResolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ - wsBaseUrl: "wss://managed.example.com/", - httpBaseUrl: "https://managed.example.com/", - accessToken: "renewed-access-token", - dpopProof: "dpop-proof", - }); - expect(productSpans.some((span) => span.name === "relay.environment.reconnect")).toBe(false); - await resetEnvironmentServiceForTests(); - }); - - it("removes an older ssh record when the same target returns a new environment id", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockFetchSshEnvironmentDescriptor.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-2"), - label: "Remote environment", - }); - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Old ssh environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-2"), - }); - - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-2"), - }), - ); - expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("retries desktop ssh session refresh when the forwarded endpoint returns ssh_http 401", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockBootstrapSshBearerSession - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }) - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token-2", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockFetchSshSessionState - .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) - .mockResolvedValueOnce({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - - const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect( - connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockEnsureSshEnvironment).toHaveBeenCalled(); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledTimes(2); - expect(mockFetchSshSessionState).toHaveBeenCalledTimes(2); - - await resetEnvironmentServiceForTests(); - }); - - it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - const authError = decodeEnvironmentAuthInvalidError({ - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-auth-test", - }); - mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError)); - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Saved environment credential expired. Pair it again."); - - expect(mockEnsureSshEnvironment).not.toHaveBeenCalled(); - expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("only registers the retried ssh connection after bearer re-issuance succeeds", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockBootstrapSshBearerSession - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }) - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token-2", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockFetchSshSessionState - .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) - .mockResolvedValueOnce({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - - const createdConnections: Array<{ - readonly environmentId: EnvironmentId; - readonly dispose: ReturnType; - }> = []; - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => { - const connection = { - kind: "saved" as const, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: vi.fn(async () => undefined), - }; - createdConnections.push(connection); - return connection; - }, - ); - - const { - connectDesktopSshEnvironment, - listEnvironmentConnections, - resetEnvironmentServiceForTests, - } = await import("./service"); - - await connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }); - - expect(createdConnections).toHaveLength(2); - expect(createdConnections[0]?.dispose).toHaveBeenCalledTimes(1); - expect(listEnvironmentConnections()).toHaveLength(1); - expect(listEnvironmentConnections()[0]).toBe(createdConnections[1]); - - await resetEnvironmentServiceForTests(); - }); - - it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const connection = { - kind: "saved" as const, - environmentId: EnvironmentId.make("environment-1"), - knownEnvironment: { - environmentId: EnvironmentId.make("environment-1"), - }, - client: { - terminal: { - onMetadata: vi.fn(() => () => undefined), - }, - preview: { - subscribePorts: vi.fn(() => () => undefined), - }, - }, - ensureBootstrapped: async () => undefined, - reconnect: vi.fn(async () => { - throw new Error("socket closed"); - }), - dispose: async () => undefined, - }; - mockCreateEnvironmentConnection.mockReturnValue(connection); - - const { addSavedEnvironment, reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await addSavedEnvironment({ - label: "Remote environment", - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }); - - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "Unable to persist saved environment credentials.", - ); - - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - lastError: "Unable to persist saved environment credentials.", - }), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("bootstraps a desktop ssh environment through the desktop bridge", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect( - connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( - { - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }, - { issuePairingToken: true }, - ); - expect(mockResolveRemotePairingTarget).toHaveBeenCalledWith({ - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - }); - expect(mockFetchSshEnvironmentDescriptor).toHaveBeenCalledWith("http://127.0.0.1:3774/"); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( - "http://127.0.0.1:3774/", - "ssh-pairing-code", - ); - expect(mockFetchRemoteEnvironmentDescriptor).not.toHaveBeenCalled(); - expect(mockBootstrapRemoteBearerSession).not.toHaveBeenCalled(); - expect(mockUpsert.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateEnvironmentConnection.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - - await resetEnvironmentServiceForTests(); - }); - - it("disconnects the desktop ssh process before removing a saved ssh environment", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await removeSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }); - expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - expect(mockDisconnectSshEnvironment.mock.invocationCallOrder[0]).toBeLessThan( - mockRemove.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - - await resetEnvironmentServiceForTests(); - }); - - it("disconnects a saved ssh environment without removing its saved record", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }); - expect(mockRemove).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("keeps remote environment credentials when disconnecting a non-ssh saved environment", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - - const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).not.toHaveBeenCalled(); - expect(mockRemove).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("cancels a pending saved environment connection when disconnected", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); - const dispose = vi.fn(async () => undefined); - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ - kind: "saved" as const, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose, - }), - ); - let resolveSessionState!: (value: { - readonly authenticated: true; - readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; - }) => void; - mockFetchRemoteSessionState.mockReturnValue( - Effect.promise( - () => - new Promise((resolve) => { - resolveSessionState = resolve; - }), - ), - ); - - const { - disconnectSavedEnvironment, - listEnvironmentConnections, - reconnectSavedEnvironment, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const reconnectPromise = reconnectSavedEnvironment(EnvironmentId.make("environment-1")); - await vi.waitFor(() => { - expect(mockFetchRemoteSessionState).toHaveBeenCalledOnce(); - }); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - resolveSessionState({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - await expect(reconnectPromise).resolves.toBeUndefined(); - - expect(listEnvironmentConnections()).toHaveLength(0); - expect(dispose).toHaveBeenCalledOnce(); - expect(mockPatchRuntime).not.toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - }), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("reissues ssh pairing credentials when connecting after a manual ssh disconnect", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await reconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( - { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - { issuePairingToken: true }, - ); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( - "http://127.0.0.1:3774/", - "ssh-pairing-code", - ); - expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "ssh-bearer-token", - ); - - await resetEnvironmentServiceForTests(); - }); - - it("rolls back ssh registry metadata when pairing token issuance fails", async () => { - const originalRecord = { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }; - mockSavedRecords = [originalRecord]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockEnsureSshEnvironment.mockResolvedValue({ - target: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - pairingToken: null, - }); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "Desktop SSH launch did not return a pairing token.", - ); - - expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledWith( - expect.objectContaining({ - httpBaseUrl: "http://127.0.0.1:3774/", - }), - ); - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([originalRecord]); - expect(mockSavedRecords).toEqual([originalRecord]); - expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("surfaces desktop ssh bootstrap failures during saved ssh reconnect", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockEnsureSshEnvironment.mockRejectedValue(new Error("SSH command timed out after 60000ms.")); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "SSH command timed out after 60000ms.", - ); - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "connecting", - }), - ); - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - lastError: "SSH command timed out after 60000ms.", - }), - ); - - await resetEnvironmentServiceForTests(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts deleted file mode 100644 index 592bc31e260..00000000000 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mockCreateEnvironmentConnection = vi.fn(); -const mockCreateWsRpcClient = vi.fn(); -const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test"); -const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); -const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); -const mockListSavedEnvironmentRecords = vi.fn(); -const mockSavedEnvironmentRegistrySubscribe = vi.fn(); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn(); - -function MockWsTransport() { - return undefined; -} - -vi.mock("../primary", () => ({ - getPrimaryKnownEnvironment: vi.fn(() => ({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - environmentId: EnvironmentId.make("env-1"), - })), -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: vi.fn(), - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: vi.fn(), - useSavedEnvironmentRegistryStore: { - subscribe: mockSavedEnvironmentRegistrySubscribe, - getState: () => ({ - upsert: vi.fn(), - remove: vi.fn(), - markConnected: vi.fn(), - rename: vi.fn(), - }), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: vi.fn(), - clear: vi.fn(), - }), - }, - waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken: vi.fn(), - writeSavedEnvironmentCredential: vi.fn(), -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createWsRpcClient: mockCreateWsRpcClient, - fetchRemoteSessionState: mockFetchRemoteSessionState, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: MockWsTransport, -})); - -vi.mock("~/composerDraftStore", () => ({ - markPromotedDraftThreadByRef: vi.fn(), - markPromotedDraftThreadsByRef: vi.fn(), - useComposerDraftStore: { - getState: () => ({ - getDraftThreadByRef: vi.fn(() => null), - clearDraftThread: vi.fn(), - }), - }, -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(() => ({ - persistence: { - setSavedEnvironmentRegistry: vi.fn(async () => undefined), - }, - })), -})); - -vi.mock("~/lib/terminalStateCleanup", () => ({ - collectActiveTerminalThreadIds: vi.fn(() => []), -})); - -vi.mock("~/orchestrationEventEffects", () => ({ - deriveOrchestrationBatchEffects: vi.fn(() => ({ - promotedThreadRefs: [], - invalidatedProviderState: false, - })), -})); - -vi.mock("~/store", () => ({ - useStore: { - getState: () => ({ - syncServerShellSnapshot: vi.fn(), - syncServerThreadDetail: vi.fn(), - removeServerThreadDetail: vi.fn(), - applyServerShellEvent: vi.fn(), - }), - }, - selectProjectsAcrossEnvironments: vi.fn(() => []), - selectSidebarThreadSummaryByRef: vi.fn(() => null), - selectThreadByRef: vi.fn(() => null), - selectThreadsAcrossEnvironments: vi.fn(() => []), -})); - -vi.mock("~/terminalStateStore", () => ({ - useTerminalStateStore: { - getState: () => ({ - applyTerminalEvent: vi.fn(), - removeTerminalState: vi.fn(), - clearTerminalSelection: vi.fn(), - }), - }, -})); - -vi.mock("~/uiStateStore", () => ({ - useUiStateStore: { - getState: () => ({ - clearThreadUi: vi.fn(), - syncPromotedDraftThreadRefs: vi.fn(), - }), - }, -})); - -const savedRecord = { - environmentId: EnvironmentId.make("env-saved"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.test/", - wsBaseUrl: "wss://remote.example.test/", -}; - -const configSnapshot = { - environment: { - environmentId: savedRecord.environmentId, - label: "Remote environment", - }, -}; - -function createClient() { - return { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - server: { - getConfig: vi.fn(async () => configSnapshot), - subscribeConfig: vi.fn(() => () => undefined), - subscribeLifecycle: vi.fn(() => () => undefined), - subscribeAuthAccess: vi.fn(() => () => undefined), - refreshProviders: vi.fn(async () => undefined), - upsertKeybinding: vi.fn(async () => undefined), - getSettings: vi.fn(async () => undefined), - updateSettings: vi.fn(async () => undefined), - }, - orchestration: { - subscribeShell: vi.fn(() => () => undefined), - subscribeThread: vi.fn(() => () => undefined), - dispatchCommand: vi.fn(async () => undefined), - getTurnDiff: vi.fn(async () => undefined), - getFullThreadDiff: vi.fn(async () => undefined), - }, - terminal: { - open: vi.fn(async () => undefined), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - clear: vi.fn(async () => undefined), - restart: vi.fn(async () => undefined), - close: vi.fn(async () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - preview: { - subscribePorts: vi.fn(() => () => undefined), - }, - projects: { - searchEntries: vi.fn(async () => []), - writeFile: vi.fn(async () => undefined), - }, - shell: { - openInEditor: vi.fn(async () => undefined), - }, - git: { - pull: vi.fn(async () => undefined), - refreshStatus: vi.fn(async () => undefined), - onStatus: vi.fn(() => () => undefined), - runStackedAction: vi.fn(async () => ({})), - listBranches: vi.fn(async () => []), - createWorktree: vi.fn(async () => undefined), - removeWorktree: vi.fn(async () => undefined), - createBranch: vi.fn(async () => undefined), - checkout: vi.fn(async () => undefined), - init: vi.fn(async () => undefined), - resolvePullRequest: vi.fn(async () => undefined), - preparePullRequestThread: vi.fn(async () => undefined), - }, - }; -} - -describe("saved environment startup", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.resetModules(); - vi.clearAllMocks(); - - mockFetchRemoteSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) => - environmentId === savedRecord.environmentId ? savedRecord : null, - ); - mockListSavedEnvironmentRecords.mockReturnValue([savedRecord]); - mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); - mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); - mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockCreateWsRpcClient.mockImplementation(() => createClient()); - mockCreateEnvironmentConnection.mockImplementation((input) => { - if (input.kind === "saved") { - queueMicrotask(() => { - input.onConfigSnapshot?.(configSnapshot); - }); - } - - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - dispose: vi.fn(async () => undefined), - }; - }); - }); - - afterEach(async () => { - const { resetEnvironmentServiceForTests } = await import("./service"); - await resetEnvironmentServiceForTests(); - vi.useRealTimers(); - }); - - it("uses the initial config snapshot instead of issuing an extra getConfig call", async () => { - const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - await vi.runAllTimersAsync(); - - const savedConnectionCall = mockCreateEnvironmentConnection.mock.calls.find( - ([input]) => input.kind === "saved", - ); - expect(savedConnectionCall).toBeDefined(); - - const savedClient = savedConnectionCall?.[0]?.client; - expect(savedClient.server.getConfig).not.toHaveBeenCalled(); - expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("coalesces hydration and registry sync so the initial saved connection only starts once", async () => { - let finishHydration!: () => void; - let finishTokenRead!: (token: string) => void; - - mockWaitForSavedEnvironmentRegistryHydration.mockImplementation( - () => - new Promise((resolve) => { - finishHydration = () => resolve(); - }), - ); - mockReadSavedEnvironmentBearerToken.mockImplementation( - () => - new Promise((resolve) => { - finishTokenRead = resolve; - }), - ); - - const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const registryListener = mockSavedEnvironmentRegistrySubscribe.mock.calls[0]?.[0]; - expect(registryListener).toBeTypeOf("function"); - - registryListener?.(); - finishHydration(); - await vi.waitFor(() => { - expect(mockReadSavedEnvironmentBearerToken).toHaveBeenCalledTimes(1); - }); - - finishTokenRead("saved-bearer-token"); - await vi.runAllTimersAsync(); - - const savedConnectionCalls = mockCreateEnvironmentConnection.mock.calls.filter( - ([input]) => input.kind === "saved", - ); - expect(savedConnectionCalls).toHaveLength(1); - expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts deleted file mode 100644 index 598368192c3..00000000000 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ /dev/null @@ -1,668 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { - EnvironmentId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationShellSnapshot, -} from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mockSubscribeThread = vi.fn(); -const mockThreadUnsubscribe = vi.fn(); -const mockCreateEnvironmentConnection = vi.fn(); -const mockCreateWsRpcClient = vi.fn(); -const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); -const mockListSavedEnvironmentRecords = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn(); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockSavedEnvironmentRegistrySubscribe = vi.fn(); -const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); -const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/"); -const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); -const mockConnectionReconnects: Array> = []; -let savedEnvironmentRegistryListener: (() => void) | null = null; - -function MockWsTransport() { - return undefined; -} - -vi.mock("../primary", () => ({ - getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment, -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: vi.fn(), - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: vi.fn(), - useSavedEnvironmentRegistryStore: { - subscribe: mockSavedEnvironmentRegistrySubscribe, - getState: () => ({ - upsert: vi.fn(), - remove: vi.fn(), - markConnected: vi.fn(), - rename: vi.fn(), - }), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: vi.fn(), - clear: vi.fn(), - }), - }, - waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken: vi.fn(), - writeSavedEnvironmentCredential: vi.fn(), -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - const stubWsClient: WsRpcClient = { - dispose: async () => undefined, - reconnect: async () => undefined, - isHeartbeatFresh: () => false, - cloud: { - getRelayClientStatus: vi.fn(), - installRelayClient: vi.fn(), - }, - orchestration: { - dispatchCommand: vi.fn(), - getTurnDiff: vi.fn(), - getFullThreadDiff: vi.fn(), - getArchivedShellSnapshot: vi.fn(), - subscribeShell: vi.fn(() => () => undefined), - subscribeThread: mockSubscribeThread, - }, - terminal: { - open: vi.fn(), - attach: vi.fn(() => () => undefined), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - restart: vi.fn(), - close: vi.fn(), - onEvent: vi.fn(() => () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - preview: { - open: vi.fn(), - navigate: vi.fn(), - refresh: vi.fn(), - close: vi.fn(), - list: vi.fn(), - reportStatus: vi.fn(), - automation: { - connect: vi.fn(() => () => undefined), - respond: vi.fn(), - reportOwner: vi.fn(), - clearOwner: vi.fn(), - }, - onEvent: vi.fn(() => () => undefined), - subscribePorts: vi.fn(() => () => undefined), - }, - projects: { - listEntries: vi.fn(), - readFile: vi.fn(), - searchEntries: vi.fn(), - writeFile: vi.fn(), - }, - filesystem: { - browse: vi.fn(), - }, - assets: { createUrl: vi.fn() }, - sourceControl: { - lookupRepository: vi.fn(), - cloneRepository: vi.fn(), - publishRepository: vi.fn(), - }, - shell: { - openInEditor: vi.fn(), - }, - vcs: { - pull: vi.fn(), - refreshStatus: vi.fn(), - onStatus: vi.fn(() => () => undefined), - listRefs: vi.fn(), - createWorktree: vi.fn(), - removeWorktree: vi.fn(), - createRef: vi.fn(), - switchRef: vi.fn(), - init: vi.fn(), - }, - git: { - runStackedAction: vi.fn(), - resolvePullRequest: vi.fn(), - preparePullRequestThread: vi.fn(), - }, - review: { - getDiffPreview: vi.fn(), - }, - server: { - getConfig: vi.fn(), - refreshProviders: vi.fn(), - discoverSourceControl: vi.fn(), - updateProvider: vi.fn(), - upsertKeybinding: vi.fn(), - removeKeybinding: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), - subscribeConfig: vi.fn(() => () => undefined), - subscribeLifecycle: vi.fn(() => () => undefined), - subscribeAuthAccess: vi.fn(() => () => undefined), - getTraceDiagnostics: vi.fn(), - getProcessDiagnostics: vi.fn(), - getProcessResourceHistory: vi.fn(), - signalProcess: vi.fn(), - }, - }; - return { - ...actual, - createWsRpcClient: vi.fn(() => stubWsClient), - fetchRemoteSessionState: mockFetchRemoteSessionState, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: MockWsTransport, -})); - -function makeThreadShellSnapshot(params: { - readonly threadId: ThreadId; - readonly sessionStatus?: - | "idle" - | "starting" - | "running" - | "ready" - | "interrupted" - | "stopped" - | "error"; - readonly hasPendingApprovals?: boolean; - readonly hasPendingUserInput?: boolean; - readonly hasActionableProposedPlan?: boolean; -}): OrchestrationShellSnapshot { - const projectId = ProjectId.make("project-1"); - const turnId = TurnId.make("turn-1"); - - return { - snapshotSequence: 1, - projects: [], - updatedAt: "2026-04-13T00:00:00.000Z", - threads: [ - { - id: params.threadId, - projectId, - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: - params.sessionStatus === "running" - ? { - turnId, - state: "running", - requestedAt: "2026-04-13T00:00:00.000Z", - startedAt: "2026-04-13T00:00:01.000Z", - completedAt: null, - assistantMessageId: null, - } - : null, - createdAt: "2026-04-13T00:00:00.000Z", - updatedAt: "2026-04-13T00:00:00.000Z", - archivedAt: null, - session: params.sessionStatus - ? { - threadId: params.threadId, - status: params.sessionStatus, - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: params.sessionStatus === "running" ? turnId : null, - lastError: null, - updatedAt: "2026-04-13T00:00:00.000Z", - } - : null, - latestUserMessageAt: null, - hasPendingApprovals: params.hasPendingApprovals ?? false, - hasPendingUserInput: params.hasPendingUserInput ?? false, - hasActionableProposedPlan: params.hasActionableProposedPlan ?? false, - goal: null, - }, - ], - }; -} - -describe("retainThreadDetailSubscription", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.resetModules(); - vi.clearAllMocks(); - mockGetPrimaryKnownEnvironment.mockReturnValue({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - environmentId: EnvironmentId.make("env-1"), - }); - - mockThreadUnsubscribe.mockImplementation(() => undefined); - mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); - mockCreateWsRpcClient.mockReturnValue({ - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-remote"), - label: "Remote env", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - })), - }, - isHeartbeatFresh: vi.fn(() => true), - orchestration: { - subscribeThread: mockSubscribeThread, - }, - }); - mockCreateEnvironmentConnection.mockImplementation((input) => { - const reconnect = vi.fn(async () => undefined); - mockConnectionReconnects.push(reconnect); - queueMicrotask(() => { - input.onConfigSnapshot?.({ - environment: { - environmentId: input.knownEnvironment.environmentId, - label: input.knownEnvironment.label, - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }); - }); - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect, - dispose: vi.fn(async () => undefined), - }; - }); - savedEnvironmentRegistryListener = null; - mockSavedEnvironmentRegistrySubscribe.mockImplementation((listener: () => void) => { - savedEnvironmentRegistryListener = listener; - return () => { - if (savedEnvironmentRegistryListener === listener) { - savedEnvironmentRegistryListener = null; - } - }; - }); - mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); - mockListSavedEnvironmentRecords.mockReturnValue([]); - mockGetSavedEnvironmentRecord.mockReturnValue(null); - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockFetchRemoteSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read"], - }); - mockConnectionReconnects.length = 0; - }); - - afterEach(async () => { - const { resetEnvironmentServiceForTests } = await import("./service"); - await resetEnvironmentServiceForTests(); - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("keeps thread detail subscriptions warm across releases until idle eviction", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-1"); - - const releaseFirst = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - releaseFirst(); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - const releaseSecond = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - releaseSecond(); - await vi.advanceTimersByTimeAsync(2 * 60 * 1000); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(28 * 60 * 1000); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("does not start the primary connection until the known environment has an id", async () => { - mockGetPrimaryKnownEnvironment.mockReturnValue({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - }); - const { - listEnvironmentConnections, - resetEnvironmentServiceForTests, - startEnvironmentConnectionService, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - - expect(mockCreateEnvironmentConnection).not.toHaveBeenCalled(); - expect(listEnvironmentConnections()).toEqual([]); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-active"); - - const connectionInput = mockCreateEnvironmentConnection.mock.calls[0]?.[0]; - expect(connectionInput).toBeDefined(); - - connectionInput.syncShellSnapshot( - makeThreadShellSnapshot({ - threadId, - sessionStatus: "ready", - hasPendingApprovals: true, - }), - environmentId, - ); - - const release = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - release(); - await vi.advanceTimersByTimeAsync(30 * 60 * 1000); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - connectionInput.applyShellEvent( - { - kind: "thread-upserted", - sequence: 2, - thread: makeThreadShellSnapshot({ - threadId, - sessionStatus: "idle", - }).threads[0]!, - }, - environmentId, - ); - - await vi.advanceTimersByTimeAsync(30 * 60 * 1000); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("reattaches retained thread detail subscriptions after a saved environment reconnect replaces the client", async () => { - const environmentId = EnvironmentId.make("env-remote"); - const threadId = ThreadId.make("thread-reconnect"); - const record = { - environmentId, - label: "Remote env", - httpBaseUrl: "http://remote.example.test", - wsBaseUrl: "ws://remote.example.test", - createdAt: "2026-05-01T00:00:00.000Z", - lastConnectedAt: "2026-05-01T00:00:00.000Z", - }; - mockListSavedEnvironmentRecords.mockReturnValue([record]); - mockGetSavedEnvironmentRecord.mockReturnValue(record); - mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); - - const { - disconnectSavedEnvironment, - listEnvironmentConnections, - reconnectSavedEnvironment, - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - savedEnvironmentRegistryListener?.(); - await vi.waitFor(() => { - expect( - listEnvironmentConnections().some( - (connection) => connection.environmentId === environmentId, - ), - ).toBe(true); - }); - const createConnectionCallsBeforeReconnect = mockCreateEnvironmentConnection.mock.calls.length; - - const release = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - await disconnectSavedEnvironment(environmentId); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - expect( - listEnvironmentConnections().some((connection) => connection.environmentId === environmentId), - ).toBe(false); - - const reconnectPromise = reconnectSavedEnvironment(environmentId); - await vi.advanceTimersByTimeAsync(200); - await reconnectPromise; - await vi.waitFor(() => { - expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes( - createConnectionCallsBeforeReconnect + 1, - ); - expect(mockSubscribeThread).toHaveBeenCalledTimes(2); - }); - - release(); - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("keeps healthy environment streams connected when the browser resumes from the background", async () => { - let visibilityState: DocumentVisibilityState = "visible"; - const documentTarget = new EventTarget(); - const windowTarget = new EventTarget(); - vi.stubGlobal("document", { - addEventListener: documentTarget.addEventListener.bind(documentTarget), - removeEventListener: documentTarget.removeEventListener.bind(documentTarget), - get visibilityState() { - return visibilityState; - }, - }); - vi.stubGlobal("window", { - addEventListener: windowTarget.addEventListener.bind(windowTarget), - removeEventListener: windowTarget.removeEventListener.bind(windowTarget), - }); - - const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = - await import("./service"); - mockCreateEnvironmentConnection.mockImplementation((input) => { - const reconnect = vi.fn(async () => undefined); - mockConnectionReconnects.push(reconnect); - queueMicrotask(() => { - input.onConfigSnapshot?.({ - environment: { - environmentId: input.knownEnvironment.environmentId, - label: input.knownEnvironment.label, - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }); - }); - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: { - ...input.client, - isHeartbeatFresh: vi.fn(() => true), - }, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect, - dispose: vi.fn(async () => undefined), - }; - }); - - const stop = startEnvironmentConnectionService(new QueryClient()); - expect(mockConnectionReconnects).toHaveLength(1); - - visibilityState = "hidden"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - visibilityState = "visible"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("reconnects stale environment streams when the browser resumes from the background", async () => { - let visibilityState: DocumentVisibilityState = "visible"; - const documentTarget = new EventTarget(); - const windowTarget = new EventTarget(); - vi.stubGlobal("document", { - addEventListener: documentTarget.addEventListener.bind(documentTarget), - removeEventListener: documentTarget.removeEventListener.bind(documentTarget), - get visibilityState() { - return visibilityState; - }, - }); - vi.stubGlobal("window", { - addEventListener: windowTarget.addEventListener.bind(windowTarget), - removeEventListener: windowTarget.removeEventListener.bind(windowTarget), - }); - mockCreateWsRpcClient.mockReturnValue({ - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-remote"), - label: "Remote env", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - })), - }, - isHeartbeatFresh: vi.fn(() => false), - orchestration: { - subscribeThread: mockSubscribeThread, - }, - }); - - const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - expect(mockConnectionReconnects).toHaveLength(1); - - visibilityState = "hidden"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - visibilityState = "visible"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("allows a larger idle cache before capacity eviction starts", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - - for (let index = 0; index < 12; index += 1) { - const release = retainThreadDetailSubscription( - environmentId, - ThreadId.make(`thread-${index + 1}`), - ); - release(); - } - - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("disposes cached thread detail subscriptions when the environment service resets", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-2"); - - const release = retainThreadDetailSubscription(environmentId, threadId); - release(); - - await resetEnvironmentServiceForTests(); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts deleted file mode 100644 index ff1162ef91f..00000000000 --- a/apps/web/src/environments/runtime/service.ts +++ /dev/null @@ -1,2103 +0,0 @@ -import { - AuthEnvironmentScope, - type DesktopSshEnvironmentBootstrap, - type DesktopSshEnvironmentTarget, - type EnvironmentId, - type OrchestrationEvent, - type OrchestrationShellSnapshot, - type OrchestrationShellStreamEvent, - type ServerConfig, - EnvironmentAuthInvalidError, - ThreadId, -} from "@t3tools/contracts"; -import { - createWsRpcClient as createBaseWsRpcClient, - type WsRpcClient, - bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, - fetchRemoteDpopSessionState, - fetchRemoteSessionState, - type ManagedRelayDpopProofInput, - ManagedRelayDpopSigner, - resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl, -} from "@t3tools/client-runtime"; - -import { type QueryClient } from "@tanstack/react-query"; -import { Throttler } from "@tanstack/react-pacer"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { Headers, HttpTraceContext } from "effect/unstable/http"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; -import { - createKnownEnvironment, - getKnownEnvironmentWsBaseUrl, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; - -import { - markPromotedDraftThreadByRef, - markPromotedDraftThreadsByRef, - useComposerDraftStore, -} from "~/composerDraftStore"; -import { ensureLocalApi } from "~/localApi"; -import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; -import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; -import { getPrimaryKnownEnvironment } from "../primary"; -import { webRuntime } from "../../lib/runtime"; -import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment"; -import { readManagedRelayClerkToken } from "../../cloud/managedAuth"; - -import { - getSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated, - listSavedEnvironmentRecords, - persistSavedEnvironmentRecord, - readSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken, - type SavedEnvironmentRecord, - type SavedEnvironmentCredential, - toPersistedSavedEnvironmentRecord, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - waitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken, - writeSavedEnvironmentCredential, -} from "./catalog"; -import { - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - EnvironmentConnectionAttemptCancelledError, - type EnvironmentConnection, -} from "./connection"; -import { - useStore, - selectProjectsAcrossEnvironments, - selectSidebarThreadSummaryByRef, - selectThreadByRef, - selectThreadsAcrossEnvironments, -} from "~/store"; -import { useTerminalUiStateStore } from "~/terminalUiStateStore"; -import { useUiStateStore } from "~/uiStateStore"; -import { getServerConfig } from "../../rpc/serverState"; -import { WsTransport } from "~/rpc/wsTransport"; -import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew"; -import { - deriveLogicalProjectKeyFromSettings, - derivePhysicalProjectKey, -} from "../../logicalProject"; - -const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); -import { getClientSettings } from "~/hooks/useSettings"; -import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; -import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState"; -import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; -import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; - -type EnvironmentServiceState = { - readonly queryClient: QueryClient; - readonly queryInvalidationThrottler: Throttler<() => void>; - refCount: number; - stop: () => void; -}; - -type ThreadDetailSubscriptionEntry = { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - unsubscribe: () => void; - unsubscribeConnectionListener: (() => void) | null; - refCount: number; - lastAccessedAt: number; - evictionTimeoutId: ReturnType | null; -}; - -const environmentConnections = new Map(); -const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); - -function isSavedEnvironmentConnectionCancelledError( - error: unknown, -): error is EnvironmentConnectionAttemptCancelledError { - return error instanceof EnvironmentConnectionAttemptCancelledError; -} - -interface PendingSavedEnvironmentConnection { - readonly isCurrent: () => boolean; - readonly promise: Promise; -} - -const savedEnvironmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); -const pendingSavedEnvironmentConnections = new Map< - EnvironmentId, - PendingSavedEnvironmentConnection ->(); -const environmentConnectionListeners = new Set<() => void>(); -const providerInvalidationListeners = new Set<() => void>(); -const threadDetailSubscriptions = new Map(); -const lastAppliedProjectionVersionByEnvironment = new Map< - EnvironmentId, - { - readonly sequence: number; - readonly updatedAt: string | null; - } ->(); -const terminalMetadataSubscriptions = new Map void>(); -const portDiscoverySubscriptions = new Map void>(); - -let activeService: EnvironmentServiceState | null = null; -let needsProviderInvalidation = false; -let lastBrowserHiddenAt: number | null = null; -let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; - -// TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): -// This file still owns web's legacy thread-detail subscription cache. Mobile -// uses createThreadDetailManager from @t3tools/client-runtime for the same -// retain/reconnect/evict lifecycle. When touching this logic, prefer migrating -// web to the shared manager or extracting the missing adapter layer instead of -// adding more behavior here. -// -// Thread detail subscription cache policy: -// - Active consumers keep a subscription retained via refCount. -// - Released subscriptions stay warm for a longer idle TTL to avoid churn -// while moving around the UI. -// - Threads with active work or pending user action are sticky and are never -// evicted while they remain non-idle. -// - Capacity eviction only targets idle cached subscriptions. -const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; -const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; -const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000; -const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; -const NOOP = () => undefined; -const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; - -const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) => - Effect.gen(function* () { - const signer = yield* ManagedRelayDpopSigner; - return yield* signer.createProof(input); - }); - -function createDeferredPromise() { - let resolve: ((value: T) => void) | null = null; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - - return { - promise, - resolve: (value: T) => { - resolve?.(value); - resolve = null; - }, - }; -} - -async function waitForConfigSnapshot( - promise: Promise, - timeoutMs: number, -): Promise { - return await new Promise((resolve) => { - const timeoutId = globalThis.setTimeout(() => resolve(null), timeoutMs); - promise.then( - (config) => { - clearTimeout(timeoutId); - resolve(config); - }, - () => { - clearTimeout(timeoutId); - resolve(null); - }, - ); - }); -} - -function createSavedEnvironmentSyncScheduler() { - let activeSync: Promise | null = null; - let queued = false; - - const run = async (): Promise => { - do { - queued = false; - await syncSavedEnvironmentConnections(listSavedEnvironmentRecords()); - } while (queued); - }; - - return () => { - if (activeSync) { - queued = true; - return activeSync; - } - - activeSync = run() - .catch(() => undefined) - .finally(() => { - activeSync = null; - }); - - return activeSync; - }; -} -function compareAppliedProjectionVersion( - left: { readonly sequence: number; readonly updatedAt: string | null }, - right: { readonly sequence: number; readonly updatedAt: string | null }, -): number { - if (left.sequence !== right.sequence) { - return left.sequence - right.sequence; - } - - const leftUpdatedAt = left.updatedAt ?? ""; - const rightUpdatedAt = right.updatedAt ?? ""; - if (leftUpdatedAt === rightUpdatedAt) { - return 0; - } - - return leftUpdatedAt < rightUpdatedAt ? -1 : 1; -} - -function toAppliedProjectionVersion( - snapshot: Pick, -): { - readonly sequence: number; - readonly updatedAt: string; -} { - return { - sequence: snapshot.snapshotSequence, - updatedAt: snapshot.updatedAt, - }; -} - -export function shouldApplyProjectionSnapshot(input: { - readonly current: { - readonly sequence: number; - readonly updatedAt: string | null; - } | null; - readonly next: Pick; -}): boolean { - if (input.current === null) { - return true; - } - - return compareAppliedProjectionVersion(input.current, toAppliedProjectionVersion(input.next)) < 0; -} - -export function shouldApplyProjectionEvent(input: { - readonly current: { - readonly sequence: number; - readonly updatedAt: string | null; - } | null; - readonly sequence: number; -}): boolean { - if (input.current === null) { - return true; - } - - return input.sequence > input.current.sequence; -} - -function readLastAppliedProjectionVersion(environmentId: EnvironmentId): { - readonly sequence: number; - readonly updatedAt: string | null; -} | null { - return lastAppliedProjectionVersionByEnvironment.get(environmentId) ?? null; -} - -function markAppliedProjectionSnapshot( - environmentId: EnvironmentId, - snapshot: Pick, -): void { - const nextVersion = toAppliedProjectionVersion(snapshot); - const currentVersion = readLastAppliedProjectionVersion(environmentId); - if ( - currentVersion !== null && - compareAppliedProjectionVersion(currentVersion, nextVersion) >= 0 - ) { - return; - } - - lastAppliedProjectionVersionByEnvironment.set(environmentId, nextVersion); -} - -function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: number): void { - const currentVersion = readLastAppliedProjectionVersion(environmentId); - if (currentVersion !== null && sequence <= currentVersion.sequence) { - return; - } - - lastAppliedProjectionVersionByEnvironment.set(environmentId, { - sequence, - updatedAt: currentVersion?.updatedAt ?? null, - }); -} -function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { - return scopedThreadKey(scopeThreadRef(environmentId, threadId)); -} - -function clearThreadDetailSubscriptionEviction( - entry: ThreadDetailSubscriptionEntry, -): ThreadDetailSubscriptionEntry { - if (entry.evictionTimeoutId !== null) { - clearTimeout(entry.evictionTimeoutId); - entry.evictionTimeoutId = null; - } - return entry; -} - -function isNonIdleThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - const threadRef = scopeThreadRef(entry.environmentId, entry.threadId); - const state = useStore.getState(); - const sidebarThread = selectSidebarThreadSummaryByRef(state, threadRef); - - // Prefer shell/sidebar state first because it carries the coarse thread - // readiness flags used throughout the UI (pending approvals/input/plan). - if (sidebarThread) { - if ( - sidebarThread.hasPendingApprovals || - sidebarThread.hasPendingUserInput || - sidebarThread.hasActionableProposedPlan - ) { - return true; - } - - const orchestrationStatus = sidebarThread.session?.orchestrationStatus; - if ( - orchestrationStatus && - orchestrationStatus !== "idle" && - orchestrationStatus !== "stopped" - ) { - return true; - } - - if (sidebarThread.latestTurn?.state === "running") { - return true; - } - } - - const thread = selectThreadByRef(state, threadRef); - if (!thread) { - return false; - } - - const orchestrationStatus = thread.session?.orchestrationStatus; - return ( - Boolean( - orchestrationStatus && orchestrationStatus !== "idle" && orchestrationStatus !== "stopped", - ) || - thread.latestTurn?.state === "running" || - thread.pendingSourceProposedPlan !== undefined - ); -} - -function shouldEvictThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - return entry.refCount === 0 && !isNonIdleThreadDetailSubscription(entry); -} - -function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - if (entry.unsubscribeConnectionListener !== null) { - entry.unsubscribeConnectionListener(); - entry.unsubscribeConnectionListener = null; - } - if (entry.unsubscribe !== NOOP) { - return true; - } - - const connection = readEnvironmentConnection(entry.environmentId); - if (!connection) { - return false; - } - - entry.unsubscribe = connection.client.orchestration.subscribeThread( - { threadId: entry.threadId }, - (item) => { - if (item.kind === "snapshot") { - useStore.getState().syncServerThreadDetail(item.snapshot.thread, entry.environmentId); - return; - } - applyEnvironmentThreadDetailEvent(item.event, entry.environmentId); - }, - ); - return true; -} - -function watchThreadDetailSubscriptionConnection(entry: ThreadDetailSubscriptionEntry): void { - if (entry.unsubscribeConnectionListener !== null) { - return; - } - - entry.unsubscribeConnectionListener = subscribeEnvironmentConnections(() => { - if (attachThreadDetailSubscription(entry)) { - entry.lastAccessedAt = Date.now(); - } - }); - attachThreadDetailSubscription(entry); -} - -function disposeThreadDetailSubscriptionByKey(key: string): boolean { - const entry = threadDetailSubscriptions.get(key); - if (!entry) { - return false; - } - - clearThreadDetailSubscriptionEviction(entry); - entry.unsubscribeConnectionListener?.(); - entry.unsubscribeConnectionListener = null; - threadDetailSubscriptions.delete(key); - entry.unsubscribe(); - entry.unsubscribe = NOOP; - return true; -} - -function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const [key, entry] of threadDetailSubscriptions) { - if (entry.environmentId === environmentId) { - disposeThreadDetailSubscriptionByKey(key); - } - } -} - -function detachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId !== environmentId) { - continue; - } - entry.unsubscribe(); - entry.unsubscribe = NOOP; - watchThreadDetailSubscriptionConnection(entry); - } -} - -function attachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId === environmentId) { - attachThreadDetailSubscription(entry); - } - } -} - -function reconcileThreadDetailSubscriptionsForEnvironment( - environmentId: EnvironmentId, - threadIds: ReadonlyArray, -): void { - const activeThreadIds = new Set(threadIds); - for (const [key, entry] of threadDetailSubscriptions) { - if (entry.environmentId === environmentId && !activeThreadIds.has(entry.threadId)) { - disposeThreadDetailSubscriptionByKey(key); - } - } -} - -function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void { - clearThreadDetailSubscriptionEviction(entry); - if (!shouldEvictThreadDetailSubscription(entry)) { - return; - } - - entry.evictionTimeoutId = setTimeout(() => { - const currentEntry = threadDetailSubscriptions.get( - getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), - ); - if (!currentEntry) { - return; - } - - currentEntry.evictionTimeoutId = null; - if (!shouldEvictThreadDetailSubscription(currentEntry)) { - return; - } - disposeThreadDetailSubscriptionByKey( - getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), - ); - }, THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS); -} - -function evictIdleThreadDetailSubscriptionsToCapacity(): void { - if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { - return; - } - - const idleEntries = [...threadDetailSubscriptions.entries()] - .filter(([, entry]) => shouldEvictThreadDetailSubscription(entry)) - .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); - - for (const [key] of idleEntries) { - if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { - return; - } - disposeThreadDetailSubscriptionByKey(key); - } -} - -function reconcileThreadDetailSubscriptionEvictionState( - entry: ThreadDetailSubscriptionEntry, -): void { - clearThreadDetailSubscriptionEviction(entry); - if (!shouldEvictThreadDetailSubscription(entry)) { - return; - } - - scheduleThreadDetailSubscriptionEviction(entry); -} - -function reconcileThreadDetailSubscriptionEvictionForThread( - environmentId: EnvironmentId, - threadId: ThreadId, -): void { - const entry = threadDetailSubscriptions.get( - getThreadDetailSubscriptionKey(environmentId, threadId), - ); - if (!entry) { - return; - } - - reconcileThreadDetailSubscriptionEvictionState(entry); -} - -function reconcileThreadDetailSubscriptionEvictionForEnvironment( - environmentId: EnvironmentId, -): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId === environmentId) { - reconcileThreadDetailSubscriptionEvictionState(entry); - } - } - evictIdleThreadDetailSubscriptionsToCapacity(); -} - -export function retainThreadDetailSubscription( - environmentId: EnvironmentId, - threadId: ThreadId, -): () => void { - const key = getThreadDetailSubscriptionKey(environmentId, threadId); - const existing = threadDetailSubscriptions.get(key); - if (existing) { - clearThreadDetailSubscriptionEviction(existing); - existing.refCount += 1; - existing.lastAccessedAt = Date.now(); - if (!attachThreadDetailSubscription(existing)) { - watchThreadDetailSubscriptionConnection(existing); - } - let released = false; - return () => { - if (released) { - return; - } - released = true; - existing.refCount = Math.max(0, existing.refCount - 1); - existing.lastAccessedAt = Date.now(); - if (existing.refCount === 0) { - reconcileThreadDetailSubscriptionEvictionState(existing); - evictIdleThreadDetailSubscriptionsToCapacity(); - } - }; - } - - const entry: ThreadDetailSubscriptionEntry = { - environmentId, - threadId, - unsubscribe: NOOP, - unsubscribeConnectionListener: null, - refCount: 1, - lastAccessedAt: Date.now(), - evictionTimeoutId: null, - }; - threadDetailSubscriptions.set(key, entry); - if (!attachThreadDetailSubscription(entry)) { - watchThreadDetailSubscriptionConnection(entry); - } - evictIdleThreadDetailSubscriptionsToCapacity(); - - let released = false; - return () => { - if (released) { - return; - } - released = true; - entry.refCount = Math.max(0, entry.refCount - 1); - entry.lastAccessedAt = Date.now(); - if (entry.refCount === 0) { - reconcileThreadDetailSubscriptionEvictionState(entry); - evictIdleThreadDetailSubscriptionsToCapacity(); - } - }; -} - -function emitEnvironmentConnectionRegistryChange() { - for (const listener of environmentConnectionListeners) { - listener(); - } -} - -function emitProviderInvalidation() { - for (const listener of providerInvalidationListeners) { - listener(); - } -} - -function getRuntimeErrorFields(error: unknown) { - return { - lastError: error instanceof Error ? error.message : String(error), - lastErrorAt: new Date().toISOString(), - } as const; -} - -function isoNow(): string { - return new Date().toISOString(); -} - -function readSshHttpErrorStatus(error: unknown): number | null { - if (!(error instanceof Error)) { - return null; - } - - const match = SSH_HTTP_STATUS_RE.exec(error.message); - if (!match) { - return null; - } - - const parsed = Number.parseInt(match[1] ?? "", 10); - return Number.isInteger(parsed) ? parsed : null; -} - -function isSshHttpAuthError(error: unknown, status: number): boolean { - return readSshHttpErrorStatus(error) === status; -} - -function isDesktopSshTargetEqual( - left: DesktopSshEnvironmentTarget | undefined, - right: DesktopSshEnvironmentTarget | undefined, -): boolean { - if (!left || !right) { - return false; - } - - return ( - left.alias === right.alias && - left.hostname === right.hostname && - left.username === right.username && - left.port === right.port - ); -} - -function findSavedEnvironmentRecordByDesktopSshTarget( - target: DesktopSshEnvironmentTarget | undefined, -): SavedEnvironmentRecord | null { - if (!target) { - return null; - } - - return ( - listSavedEnvironmentRecords().find((record) => - isDesktopSshTargetEqual(record.desktopSsh, target), - ) ?? null - ); -} - -function buildSavedEnvironmentRegistryById( - records: ReadonlyArray, -): Record { - return Object.fromEntries(records.map((record) => [record.environmentId, record])) as Record< - EnvironmentId, - SavedEnvironmentRecord - >; -} - -type SavedEnvironmentRegistrySnapshot = ReadonlyMap; - -function snapshotSavedEnvironmentRegistry( - environmentIds: ReadonlyArray, -): SavedEnvironmentRegistrySnapshot { - return new Map( - environmentIds.map((environmentId) => [ - environmentId, - getSavedEnvironmentRecord(environmentId) ?? null, - ]), - ); -} - -async function persistSavedEnvironmentRegistryRollback( - snapshot: SavedEnvironmentRegistrySnapshot, -): Promise { - const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); - for (const [environmentId, record] of snapshot) { - if (record) { - byId[environmentId] = record; - continue; - } - delete byId[environmentId]; - } - const records = Object.values(byId); - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), - ); - useSavedEnvironmentRegistryStore.setState({ - byId, - }); -} - -async function resolveDesktopSshEnvironmentBootstrap( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, -): Promise { - const desktopBridge = window.desktopBridge; - if (!desktopBridge) { - throw new Error("SSH launch is only available in the desktop app."); - } - - return await desktopBridge.ensureSshEnvironment(target, options); -} - -function getDesktopSshBridge() { - const desktopBridge = window.desktopBridge; - if (!desktopBridge) { - throw new Error("SSH launch is only available in the desktop app."); - } - return desktopBridge; -} - -async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) { - return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl); -} - -async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) { - return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); -} - -function readIssuedBearerScopes(scope: string): ReadonlyArray { - return decodeIssuedBearerScopes(scope.split(" ")); -} - -async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { - return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); -} - -async function resolveDesktopSshWebSocketBaseUrl( - wsBaseUrl: string, - httpBaseUrl: string, - bearerToken: string, -) { - const issued = await getDesktopSshBridge().issueSshWebSocketTicket(httpBaseUrl, bearerToken); - const url = new URL(wsBaseUrl, window.location.origin); - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -} - -async function prepareSavedEnvironmentRecordForConnection( - record: SavedEnvironmentRecord, - options?: { readonly issuePairingToken?: boolean }, -): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly pairingToken: string | null; - readonly remotePort: number | null; - readonly remoteServerKind: "external" | "managed" | null; -}> { - if (!record.desktopSsh) { - return { - record, - pairingToken: null, - remotePort: null, - remoteServerKind: null, - }; - } - - const bootstrap = await resolveDesktopSshEnvironmentBootstrap(record.desktopSsh, options); - const nextRecord: SavedEnvironmentRecord = { - ...record, - httpBaseUrl: bootstrap.httpBaseUrl, - wsBaseUrl: bootstrap.wsBaseUrl, - desktopSsh: bootstrap.target, - }; - - if ( - nextRecord.httpBaseUrl !== record.httpBaseUrl || - nextRecord.wsBaseUrl !== record.wsBaseUrl || - !isDesktopSshTargetEqual(nextRecord.desktopSsh, record.desktopSsh) - ) { - await persistSavedEnvironmentRecord(nextRecord); - useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); - } - - return { - record: nextRecord, - pairingToken: bootstrap.pairingToken, - remotePort: bootstrap.remotePort ?? null, - remoteServerKind: bootstrap.remoteServerKind ?? null, - }; -} - -async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly bearerToken: string; - readonly scopes: ReadonlyArray | null; -}> { - const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); - const prepared = await prepareSavedEnvironmentRecordForConnection(record, { - issuePairingToken: true, - }); - if (!prepared.pairingToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Desktop SSH launch did not return a pairing token."); - } - - const bearerSession = await bootstrapDesktopSshBearerSession( - prepared.record.httpBaseUrl, - prepared.pairingToken, - ).catch(async (error) => { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - const detail = [ - `local ${prepared.record.httpBaseUrl}`, - `remote port ${prepared.remotePort ?? "unknown"}`, - prepared.remoteServerKind ? `remote server ${prepared.remoteServerKind}` : null, - ] - .filter(Boolean) - .join(", "); - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${message} (${detail})`); - }); - const didPersistBearerToken = await writeSavedEnvironmentBearerToken( - prepared.record.environmentId, - bearerSession.access_token, - ); - if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Unable to persist saved environment credentials."); - } - - return { - record: prepared.record, - bearerToken: bearerSession.access_token, - scopes: readIssuedBearerScopes(bearerSession.scope), - }; -} - -function setRuntimeConnecting(environmentId: EnvironmentId) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connecting", - lastError: null, - lastErrorAt: null, - }); -} - -function setRuntimeConnected(environmentId: EnvironmentId) { - const connectedAt = isoNow(); - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connected", - authState: "authenticated", - connectedAt, - disconnectedAt: null, - lastError: null, - lastErrorAt: null, - }); - useSavedEnvironmentRegistryStore.getState().markConnected(environmentId, connectedAt); -} - -function setRuntimeDisconnected(environmentId: EnvironmentId, reason?: string | null) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "disconnected", - disconnectedAt: isoNow(), - ...(reason && reason.trim().length > 0 - ? { - lastError: reason, - lastErrorAt: isoNow(), - } - : {}), - }); -} - -function setRuntimeError(environmentId: EnvironmentId, error: unknown) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "error", - ...getRuntimeErrorFields(error), - }); -} - -function coalesceOrchestrationUiEvents( - events: ReadonlyArray, -): OrchestrationEvent[] { - if (events.length < 2) { - return [...events]; - } - - const coalesced: OrchestrationEvent[] = []; - for (const event of events) { - const previous = coalesced.at(-1); - if ( - previous?.type === "thread.message-sent" && - event.type === "thread.message-sent" && - previous.payload.threadId === event.payload.threadId && - previous.payload.messageId === event.payload.messageId - ) { - coalesced[coalesced.length - 1] = { - ...event, - payload: { - ...event.payload, - attachments: event.payload.attachments ?? previous.payload.attachments, - createdAt: previous.payload.createdAt, - text: - !event.payload.streaming && event.payload.text.length > 0 - ? event.payload.text - : previous.payload.text + event.payload.text, - }, - }; - continue; - } - - coalesced.push(event); - } - - return coalesced; -} - -function syncProjectUiFromStore() { - const projects = selectProjectsAcrossEnvironments(useStore.getState()); - const clientSettings = getClientSettings(); - useUiStateStore.getState().syncProjects( - projects.map((project) => ({ - key: derivePhysicalProjectKey(project), - logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), - cwd: project.cwd, - })), - ); -} - -function syncThreadUiFromStore() { - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncThreads( - threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - seedVisitedAt: thread.updatedAt ?? thread.createdAt, - })), - ); - markPromotedDraftThreadsByRef( - threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), - ); -} - -function reconcileSnapshotDerivedState() { - syncProjectUiFromStore(); - syncThreadUiFromStore(); - - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - const activeThreadKeys = collectActiveTerminalUiThreadKeys({ - snapshotThreads: threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - deletedAt: null, - archivedAt: thread.archivedAt, - })), - draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), - }); - useTerminalUiStateStore.getState().removeOrphanedTerminalUiStates(activeThreadKeys); -} - -function applyRecoveredEventBatch( - events: ReadonlyArray, - environmentId: EnvironmentId, -) { - if (events.length === 0) { - return; - } - - const batchEffects = deriveOrchestrationBatchEffects(events); - const uiEvents = coalesceOrchestrationUiEvents(events); - const needsProjectUiSync = events.some( - (event) => - event.type === "project.created" || - event.type === "project.meta-updated" || - event.type === "project.deleted", - ); - - if (batchEffects.needsProviderInvalidation) { - needsProviderInvalidation = true; - void activeService?.queryInvalidationThrottler.maybeExecute(); - } - - useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); - if (needsProjectUiSync) { - const projects = selectProjectsAcrossEnvironments(useStore.getState()); - const clientSettings = getClientSettings(); - useUiStateStore.getState().syncProjects( - projects.map((project) => ({ - key: derivePhysicalProjectKey(project), - logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), - cwd: project.cwd, - })), - ); - } - - const needsThreadUiSync = events.some( - (event) => event.type === "thread.created" || event.type === "thread.deleted", - ); - if (needsThreadUiSync) { - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncThreads( - threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - seedVisitedAt: thread.updatedAt ?? thread.createdAt, - })), - ); - } - - const draftStore = useComposerDraftStore.getState(); - for (const threadId of batchEffects.promoteDraftThreadIds) { - markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); - } - for (const threadId of batchEffects.clearDeletedThreadIds) { - draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); - useUiStateStore - .getState() - .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); - } - for (const event of events) { - if (event.type === "project.deleted") { - draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); - } - } - for (const threadId of batchEffects.removeTerminalUiStateThreadIds) { - useTerminalUiStateStore - .getState() - .removeTerminalUiState(scopeThreadRef(environmentId, threadId)); - } - - reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); -} - -export function applyEnvironmentThreadDetailEvent( - event: OrchestrationEvent, - environmentId: EnvironmentId, -) { - applyRecoveredEventBatch([event], environmentId); -} - -function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { - if ( - !shouldApplyProjectionEvent({ - current: readLastAppliedProjectionVersion(environmentId), - sequence: event.sequence, - }) - ) { - return; - } - - const threadId = - event.kind === "thread-upserted" - ? event.thread.id - : event.kind === "thread-removed" - ? event.threadId - : null; - const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null; - const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; - - useStore.getState().applyShellEvent(event, environmentId); - markAppliedProjectionEvent(environmentId, event.sequence); - - switch (event.kind) { - case "project-upserted": - case "project-removed": - syncProjectUiFromStore(); - return; - case "thread-upserted": - syncThreadUiFromStore(); - if (!previousThread && threadRef) { - markPromotedDraftThreadByRef(threadRef); - } - if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { - useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); - } - reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); - evictIdleThreadDetailSubscriptionsToCapacity(); - return; - case "thread-removed": - if (threadRef) { - disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef)); - useComposerDraftStore.getState().clearDraftThread(threadRef); - useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); - useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); - } - syncThreadUiFromStore(); - return; - } -} - -function createEnvironmentConnectionHandlers() { - return { - applyShellEvent, - syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Shell snapshots already have createShellSnapshotManager in - // @t3tools/client-runtime. Web currently projects snapshots straight into - // its denormalized Zustand store; future shell changes should migrate or - // bridge to the shared manager instead of growing this handler. - if ( - !shouldApplyProjectionSnapshot({ - current: readLastAppliedProjectionVersion(environmentId), - next: snapshot, - }) - ) { - return; - } - - useStore.getState().syncServerShellSnapshot(snapshot, environmentId); - markAppliedProjectionSnapshot(environmentId, snapshot); - reconcileThreadDetailSubscriptionsForEnvironment( - environmentId, - snapshot.threads.map((thread) => thread.id), - ); - reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); - reconcileSnapshotDerivedState(); - }, - }; -} - -function createWsRpcClient(transport: WsTransport): WsRpcClient { - return createBaseWsRpcClient(transport, { - beforeReconnect: () => resetWsReconnectBackoff(), - }); -} - -function createPrimaryEnvironmentClient( - knownEnvironment: ReturnType, -) { - const wsBaseUrl = getKnownEnvironmentWsBaseUrl(knownEnvironment); - if (!wsBaseUrl) { - throw new Error( - `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`, - ); - } - const connectionLabel = knownEnvironment?.label ?? null; - - return createWsRpcClient( - new WsTransport(wsBaseUrl, { - getConnectionLabel: () => connectionLabel, - getVersionMismatchHint: () => - resolveServerConfigVersionMismatch(getServerConfig())?.hint ?? null, - }), - ); -} - -function createSavedEnvironmentClient( - environmentId: EnvironmentId, - credentialRef: { current: SavedEnvironmentCredential }, - relayTraceHeadersRef: { current: Headers.Headers | null }, -): WsRpcClient { - useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); - - return createWsRpcClient( - new WsTransport( - async () => { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error(`Saved environment ${environmentId} not found.`); - } - const credential = credentialRef.current; - if (record.desktopSsh) { - if (credential.method !== "bearer") { - throw new Error("SSH environments require bearer credentials."); - } - return await resolveDesktopSshWebSocketBaseUrl( - record.wsBaseUrl, - record.httpBaseUrl, - credential.token, - ); - } - if (credential.method === "dpop") { - try { - const relayTraceHeaders = relayTraceHeadersRef.current; - relayTraceHeadersRef.current = null; - return await webRuntime.runPromise( - resolveManagedRelayWebSocketUrl(record, credential, relayTraceHeaders), - ); - } catch (error) { - if (!isEnvironmentAuthInvalidError(error)) { - throw error; - } - const renewed = await renewManagedRelayCredential(record); - if (!renewed || renewed.credential.method !== "dpop") { - throw error; - } - const renewedCredential = renewed.credential; - credentialRef.current = renewedCredential; - return await webRuntime.runPromise( - resolveManagedRelayWebSocketUrl( - renewed.record, - renewedCredential, - renewed.relayTraceHeaders, - ), - ); - } - } - return await webRuntime.runPromise( - resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - bearerToken: credential.token, - }), - ); - }, - { - getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, - getVersionMismatchHint: () => - resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - )?.hint ?? null, - onAttempt: () => { - setRuntimeConnecting(environmentId); - }, - onOpen: () => { - setRuntimeConnected(environmentId); - }, - onError: (message: string) => { - const mismatch = resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - ); - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "error", - lastError: appendVersionMismatchHint(message, mismatch), - lastErrorAt: isoNow(), - }); - }, - onClose: (details: { readonly code: number; readonly reason: string }) => { - setRuntimeDisconnected( - environmentId, - appendVersionMismatchHint( - details.reason, - resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - ), - ), - ); - }, - }, - ), - ); -} - -async function refreshSavedEnvironmentMetadata( - environmentId: EnvironmentId, - credential: SavedEnvironmentCredential, - client: WsRpcClient, - scopeHint?: ReadonlyArray | null, - configHint?: ServerConfig | null, -): Promise { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error(`Saved environment ${environmentId} not found.`); - } - - const [serverConfig, sessionState] = await Promise.all([ - configHint ? Promise.resolve(configHint) : client.server.getConfig(), - record.desktopSsh - ? credential.method === "bearer" - ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token) - : Promise.reject(new Error("SSH environments require bearer credentials.")) - : credential.method === "dpop" - ? webRuntime.runPromise( - createManagedRelayDpopProof({ - method: "GET", - url: new URL("/api/auth/session", record.httpBaseUrl).toString(), - accessToken: credential.accessToken, - }).pipe( - Effect.flatMap((proof) => - fetchRemoteDpopSessionState({ - httpBaseUrl: record.httpBaseUrl, - accessToken: credential.accessToken, - dpopProof: proof, - }), - ), - ), - ) - : webRuntime.runPromise( - fetchRemoteSessionState({ - httpBaseUrl: record.httpBaseUrl, - bearerToken: credential.token, - }), - ), - ]); - - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - authState: sessionState.authenticated ? "authenticated" : "requires-auth", - descriptor: serverConfig.environment, - serverConfig, - scopes: sessionState.authenticated ? (sessionState.scopes ?? scopeHint ?? null) : null, - }); - useSavedEnvironmentRegistryStore - .getState() - .rename(record.environmentId, serverConfig.environment.label); -} - -const resolveManagedRelayWebSocketUrl = Effect.fn( - "web.environment.resolveManagedRelayWebSocketUrl", -)(function* ( - record: SavedEnvironmentRecord, - credential: Extract, - traceHeaders: Headers.Headers | null, -) { - const request = createManagedRelayDpopProof({ - method: "POST", - url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), - accessToken: credential.accessToken, - }).pipe( - Effect.flatMap((proof) => - resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - accessToken: credential.accessToken, - dpopProof: proof, - }), - ), - ); - const parent = traceHeaders ? HttpTraceContext.fromHeaders(traceHeaders) : Option.none(); - return yield* ( - Option.isSome(parent) - ? request.pipe(Effect.withParentSpan(parent.value)) - : request.pipe( - Effect.withSpan("relay.environment.reconnect", { - root: true, - attributes: { "relay.environment_id": record.environmentId }, - }), - ) - ).pipe(withRelayClientTracing); -}); - -async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly credential: SavedEnvironmentCredential; - readonly relayTraceHeaders: Headers.Headers; -} | null> { - if (!record.relayManaged) { - return null; - } - const clerkToken = await readManagedRelayClerkToken(); - if (!clerkToken) { - return null; - } - const connected = await webRuntime.runPromise( - connectManagedCloudEnvironment({ - clerkToken, - relayUrl: record.relayManaged.relayUrl, - environment: { - environmentId: record.environmentId, - label: record.label, - linkedAt: record.createdAt, - endpoint: { - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - providerKind: "cloudflare_tunnel", - }, - }, - }), - ); - const nextRecord: SavedEnvironmentRecord = { - ...record, - label: connected.label, - httpBaseUrl: connected.httpBaseUrl, - wsBaseUrl: connected.wsBaseUrl, - }; - const credential: SavedEnvironmentCredential = { - version: 1, - method: "dpop", - accessToken: connected.accessToken, - }; - await persistSavedEnvironmentRecord(nextRecord); - if (!(await writeSavedEnvironmentCredential(nextRecord.environmentId, credential))) { - throw new Error("Unable to persist refreshed managed environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); - return { record: nextRecord, credential, relayTraceHeaders: connected.relayTraceHeaders }; -} - -function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { - const existing = environmentConnections.get(connection.environmentId); - if (existing && existing !== connection) { - throw new Error(`Environment ${connection.environmentId} already has an active connection.`); - } - environmentConnections.set(connection.environmentId, connection); - terminalMetadataSubscriptions.get(connection.environmentId)?.(); - terminalMetadataSubscriptions.set( - connection.environmentId, - subscribeTerminalMetadata({ - environmentId: connection.environmentId, - client: connection.client, - }), - ); - portDiscoverySubscriptions.get(connection.environmentId)?.(); - portDiscoverySubscriptions.set( - connection.environmentId, - subscribePortDiscovery({ - environmentId: connection.environmentId, - previewApi: connection.client.preview, - }), - ); - attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); - emitEnvironmentConnectionRegistryChange(); - return connection; -} - -async function removeConnection(environmentId: EnvironmentId): Promise { - const connection = environmentConnections.get(environmentId); - if (!connection) { - return false; - } - - lastAppliedProjectionVersionByEnvironment.delete(environmentId); - environmentConnections.delete(environmentId); - terminalMetadataSubscriptions.get(environmentId)?.(); - terminalMetadataSubscriptions.delete(environmentId); - portDiscoverySubscriptions.get(environmentId)?.(); - portDiscoverySubscriptions.delete(environmentId); - usePortDiscoveryStore.getState().clearEnvironment(environmentId); - terminalSessionManager.invalidateEnvironment(environmentId); - emitEnvironmentConnectionRegistryChange(); - detachThreadDetailSubscriptionsForEnvironment(environmentId); - await connection.dispose(); - return true; -} - -function createPrimaryEnvironmentConnection(): EnvironmentConnection { - const knownEnvironment = getPrimaryKnownEnvironment(); - if (!knownEnvironment?.environmentId) { - throw new Error("Unable to resolve the primary environment."); - } - - const existing = environmentConnections.get(knownEnvironment.environmentId); - if (existing) { - return existing; - } - - return registerConnection( - createEnvironmentConnection({ - kind: "primary", - knownEnvironment, - client: createPrimaryEnvironmentClient(knownEnvironment), - ...createEnvironmentConnectionHandlers(), - }), - ); -} - -function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null { - return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null; -} - -async function ensureSavedEnvironmentConnection( - record: SavedEnvironmentRecord, - options?: { - readonly client?: WsRpcClient; - readonly bearerToken?: string; - readonly credential?: SavedEnvironmentCredential; - readonly scopes?: ReadonlyArray | null; - readonly serverConfig?: ServerConfig | null; - readonly allowManagedRenewal?: boolean; - readonly relayTraceHeaders?: Headers.Headers; - }, -): Promise { - const existing = environmentConnections.get(record.environmentId); - if (existing) { - return existing; - } - - const pending = pendingSavedEnvironmentConnections.get(record.environmentId); - if (pending) { - return pending.promise; - } - - const attempt = savedEnvironmentConnectionAttempts.begin(record.environmentId); - const pendingEntry: PendingSavedEnvironmentConnection = { - isCurrent: attempt.isCurrent, - promise: Promise.resolve().then(async () => { - let activeRecord = record; - let scopeHint = options?.scopes ?? null; - let credential = - options?.credential ?? - (options?.bearerToken - ? ({ version: 1, method: "bearer", token: options.bearerToken } as const) - : await readSavedEnvironmentCredential(record.environmentId)); - if (!credential) { - if (record.desktopSsh) { - const issued = await issueDesktopSshBearerSession(record); - activeRecord = issued.record; - credential = { version: 1, method: "bearer", token: issued.bearerToken }; - scopeHint = issued.scopes; - } else { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - authState: "requires-auth", - scopes: null, - connectionState: "disconnected", - lastError: "Saved environment is missing its saved credential. Pair it again.", - lastErrorAt: isoNow(), - }); - throw new Error("Saved environment is missing its saved credential."); - } - } else { - const prepared = await prepareSavedEnvironmentRecordForConnection(record); - activeRecord = prepared.record; - } - - const activeCredential = { current: credential }; - const relayTraceHeaders = { current: options?.relayTraceHeaders ?? null }; - const client = - options?.client ?? - createSavedEnvironmentClient( - activeRecord.environmentId, - activeCredential, - relayTraceHeaders, - ); - const initialConfigSnapshot = createDeferredPromise(); - const knownEnvironment = createKnownEnvironment({ - id: activeRecord.environmentId, - label: activeRecord.label, - source: "manual", - target: { - httpBaseUrl: activeRecord.httpBaseUrl, - wsBaseUrl: activeRecord.wsBaseUrl, - }, - }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...knownEnvironment, - environmentId: activeRecord.environmentId, - }, - client, - refreshMetadata: async () => { - await refreshSavedEnvironmentMetadata( - activeRecord.environmentId, - activeCredential.current, - client, - ); - }, - onConfigSnapshot: (config) => { - initialConfigSnapshot.resolve(config); - useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { - descriptor: config.environment, - serverConfig: config, - }); - }, - onWelcome: (payload) => { - useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { - descriptor: payload.environment, - }); - }, - ...createEnvironmentConnectionHandlers(), - }); - - try { - try { - const initialServerConfig = - options?.serverConfig ?? - (await waitForConfigSnapshot( - initialConfigSnapshot.promise, - INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS, - )); - await refreshSavedEnvironmentMetadata( - activeRecord.environmentId, - activeCredential.current, - client, - scopeHint, - initialServerConfig, - ); - } catch (error) { - const isAuthError = activeRecord.desktopSsh - ? isSshHttpAuthError(error, 401) - : isEnvironmentAuthInvalidError(error); - if (!isAuthError) { - throw error; - } - if (!activeRecord.desktopSsh) { - if ( - activeCredential.current.method === "dpop" && - options?.allowManagedRenewal !== false - ) { - const renewed = await renewManagedRelayCredential(activeRecord); - if (renewed) { - await connection.dispose().catch(() => undefined); - pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); - return await ensureSavedEnvironmentConnection(renewed.record, { - credential: renewed.credential, - scopes: scopeHint, - serverConfig: options?.serverConfig ?? null, - allowManagedRenewal: false, - relayTraceHeaders: renewed.relayTraceHeaders, - }); - } - } - await removeSavedEnvironmentBearerToken(activeRecord.environmentId); - throw new Error( - activeCredential.current.method === "dpop" - ? "Managed tunnel credential expired. Connect it again from T3 Connect." - : "Saved environment credential expired. Pair it again.", - { - cause: error, - }, - ); - } - - const issued = await issueDesktopSshBearerSession(activeRecord); - activeRecord = issued.record; - credential = { version: 1, method: "bearer", token: issued.bearerToken }; - scopeHint = issued.scopes; - await connection.dispose().catch(() => undefined); - pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); - return await ensureSavedEnvironmentConnection(activeRecord, { - credential, - scopes: scopeHint, - serverConfig: options?.serverConfig ?? null, - }); - } - if ( - !pendingEntry.isCurrent() || - pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry - ) { - await connection.dispose().catch(() => undefined); - throw new EnvironmentConnectionAttemptCancelledError(activeRecord.environmentId); - } - registerConnection(connection); - return connection; - } catch (error) { - if (error instanceof EnvironmentConnectionAttemptCancelledError) { - throw error; - } - setRuntimeError(activeRecord.environmentId, error); - const removed = await removeConnection(activeRecord.environmentId).catch(() => false); - if (!removed) { - await connection.dispose().catch(() => undefined); - } - throw error; - } - }), - }; - - pendingSavedEnvironmentConnections.set(record.environmentId, pendingEntry); - return await pendingEntry.promise.finally(() => { - if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) { - pendingSavedEnvironmentConnections.delete(record.environmentId); - savedEnvironmentConnectionAttempts.cancel(record.environmentId); - } - }); -} - -async function syncSavedEnvironmentConnections( - records: ReadonlyArray, -): Promise { - const expectedEnvironmentIds = new Set(records.map((record) => record.environmentId)); - const staleEnvironmentIds: EnvironmentId[] = []; - for (const connection of environmentConnections.values()) { - if (connection.kind !== "saved") continue; - if (expectedEnvironmentIds.has(connection.environmentId)) continue; - staleEnvironmentIds.push(connection.environmentId); - } - - await Promise.all( - staleEnvironmentIds.map((environmentId) => disconnectSavedEnvironment(environmentId)), - ); - await Promise.all( - records.map((record) => ensureSavedEnvironmentConnection(record).catch(() => undefined)), - ); -} - -function stopActiveService() { - activeService?.stop(); - activeService = null; -} - -function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void { - const now = Date.now(); - if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) { - return; - } - - for (const connection of environmentConnections.values()) { - if (connection.client.isHeartbeatFresh()) { - continue; - } - lastBrowserResumeReconnectAt = now; - void connection.reconnect().catch((error) => { - console.warn("Environment reconnect after browser resume failed", { - environmentId: connection.environmentId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - }); - } -} - -function subscribeBrowserResumeReconnects(): () => void { - if (typeof document === "undefined" || typeof window === "undefined") { - return NOOP; - } - - const handleVisibilityChange = () => { - if (document.visibilityState === "hidden") { - lastBrowserHiddenAt = Date.now(); - return; - } - if (document.visibilityState === "visible" && lastBrowserHiddenAt !== null) { - lastBrowserHiddenAt = null; - reconnectEnvironmentConnectionsAfterBrowserResume("visibilitychange"); - } - }; - - const handlePageShow = (event: PageTransitionEvent) => { - if (event.persisted || lastBrowserHiddenAt !== null) { - lastBrowserHiddenAt = null; - reconnectEnvironmentConnectionsAfterBrowserResume("pageshow"); - } - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("pageshow", handlePageShow); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("pageshow", handlePageShow); - }; -} - -export function subscribeEnvironmentConnections(listener: () => void): () => void { - environmentConnectionListeners.add(listener); - return () => { - environmentConnectionListeners.delete(listener); - }; -} - -export function subscribeProviderInvalidations(listener: () => void): () => void { - providerInvalidationListeners.add(listener); - return () => { - providerInvalidationListeners.delete(listener); - }; -} - -export function listEnvironmentConnections(): ReadonlyArray { - return [...environmentConnections.values()]; -} - -export function readEnvironmentConnection( - environmentId: EnvironmentId, -): EnvironmentConnection | null { - return environmentConnections.get(environmentId) ?? null; -} - -export function requireEnvironmentConnection(environmentId: EnvironmentId): EnvironmentConnection { - const connection = readEnvironmentConnection(environmentId); - if (!connection) { - throw new Error(`No websocket client registered for environment ${environmentId}.`); - } - return connection; -} - -export function getPrimaryEnvironmentConnection(): EnvironmentConnection { - return createPrimaryEnvironmentConnection(); -} - -export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise { - const record = getSavedEnvironmentRecord(environmentId); - const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId); - if (pendingConnection) { - savedEnvironmentConnectionAttempts.cancel(environmentId); - pendingSavedEnvironmentConnections.delete(environmentId); - } - const connection = environmentConnections.get(environmentId); - - if (connection?.kind === "saved") { - await removeConnection(environmentId).catch(() => false); - } - setRuntimeDisconnected(environmentId); - - if (record?.desktopSsh && typeof window !== "undefined") { - await window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh); - await removeSavedEnvironmentBearerToken(environmentId); - } -} - -export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error("Saved environment not found."); - } - - const connection = environmentConnections.get(environmentId); - if (!connection) { - setRuntimeConnecting(environmentId); - try { - await ensureSavedEnvironmentConnection(record); - return; - } catch (error) { - if (isSavedEnvironmentConnectionCancelledError(error)) { - return; - } - setRuntimeError(environmentId, error); - throw error; - } - } - - setRuntimeConnecting(environmentId); - try { - if (record.desktopSsh) { - await prepareSavedEnvironmentRecordForConnection(record); - } - await connection.reconnect(); - } catch (error) { - if (record.desktopSsh) { - try { - const issued = await issueDesktopSshBearerSession( - getSavedEnvironmentRecord(environmentId) ?? record, - ); - await removeConnection(environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(issued.record, { - bearerToken: issued.bearerToken, - scopes: issued.scopes, - }); - return; - } catch (recoveryError) { - if (isSavedEnvironmentConnectionCancelledError(recoveryError)) { - return; - } - setRuntimeError(environmentId, recoveryError); - throw recoveryError; - } - } - setRuntimeError(environmentId, error); - throw error; - } -} - -export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise { - await disconnectSavedEnvironment(environmentId); - disposeThreadDetailSubscriptionsForEnvironment(environmentId); - useSavedEnvironmentRegistryStore.getState().remove(environmentId); - useSavedEnvironmentRuntimeStore.getState().clear(environmentId); - useStore.getState().removeEnvironmentState(environmentId); - await removeSavedEnvironmentBearerToken(environmentId); -} - -export async function addSavedEnvironment(input: { - readonly label: string; - readonly pairingUrl?: string; - readonly host?: string; - readonly pairingCode?: string; - readonly desktopSsh?: DesktopSshEnvironmentTarget; -}): Promise { - const resolvedTarget = resolveRemotePairingTarget({ - ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), - ...(input.host !== undefined ? { host: input.host } : {}), - ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), - }); - const descriptor = input.desktopSsh - ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) - : await webRuntime.runPromise( - fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - }), - ); - const environmentId = descriptor.environmentId; - const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); - const existingRecord = - getSavedEnvironmentRecord(environmentId) ?? - findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); - const staleDesktopSshRecord = - existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null; - - const bearerSession = input.desktopSsh - ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) - : await webRuntime.runPromise( - bootstrapRemoteBearerSession({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - credential: resolvedTarget.credential, - }), - ); - - const record: SavedEnvironmentRecord = { - environmentId, - label: input.label.trim() || existingRecord?.label || descriptor.label, - wsBaseUrl: resolvedTarget.wsBaseUrl, - httpBaseUrl: resolvedTarget.httpBaseUrl, - createdAt: existingRecord?.createdAt ?? isoNow(), - lastConnectedAt: isoNow(), - ...((input.desktopSsh ?? existingRecord?.desktopSsh) - ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh } - : {}), - }; - - await persistSavedEnvironmentRecord(record); - const didPersistBearerToken = await writeSavedEnvironmentBearerToken( - environmentId, - bearerSession.access_token, - ); - if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Unable to persist saved environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(record); - if (staleDesktopSshRecord) { - await removeSavedEnvironment(staleDesktopSshRecord.environmentId); - } - await removeConnection(environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(record, { - bearerToken: bearerSession.access_token, - scopes: readIssuedBearerScopes(bearerSession.scope), - }); - return record; -} - -export async function addManagedRelayEnvironment(input: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly relayUrl: string; - readonly accessToken: string; - readonly relayTraceHeaders: Headers.Headers; -}): Promise { - const existingRecord = getSavedEnvironmentRecord(input.environmentId); - const record: SavedEnvironmentRecord = { - environmentId: input.environmentId, - label: input.label.trim() || existingRecord?.label || "Managed environment", - httpBaseUrl: input.httpBaseUrl, - wsBaseUrl: input.wsBaseUrl, - createdAt: existingRecord?.createdAt ?? isoNow(), - lastConnectedAt: isoNow(), - relayManaged: { relayUrl: input.relayUrl }, - }; - const credential: SavedEnvironmentCredential = { - version: 1, - method: "dpop", - accessToken: input.accessToken, - }; - - await persistSavedEnvironmentRecord(record); - if (!(await writeSavedEnvironmentCredential(record.environmentId, credential))) { - throw new Error("Unable to persist managed environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(record); - await removeConnection(record.environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(record, { - credential, - relayTraceHeaders: input.relayTraceHeaders, - }); - return record; -} - -export async function connectDesktopSshEnvironment( - target: DesktopSshEnvironmentTarget, - options?: { label?: string }, -): Promise { - const bootstrap = await resolveDesktopSshEnvironmentBootstrap(target, { - issuePairingToken: true, - }); - if (!bootstrap.pairingToken) { - throw new Error("Desktop SSH launch did not return a pairing token."); - } - - return await addSavedEnvironment({ - label: options?.label?.trim() || bootstrap.target.alias, - host: bootstrap.httpBaseUrl, - pairingCode: bootstrap.pairingToken, - desktopSsh: bootstrap.target, - }).catch((error) => { - const detail = [ - `local ${bootstrap.httpBaseUrl}`, - `remote port ${bootstrap.remotePort ?? "unknown"}`, - bootstrap.remoteServerKind ? `remote server ${bootstrap.remoteServerKind}` : null, - ] - .filter(Boolean) - .join(", "); - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${message} (${detail})`); - }); -} - -export async function ensureEnvironmentConnectionBootstrapped( - environmentId: EnvironmentId, -): Promise { - await environmentConnections.get(environmentId)?.ensureBootstrapped(); -} - -export function startEnvironmentConnectionService(queryClient: QueryClient): () => void { - if (activeService?.queryClient === queryClient) { - activeService.refCount += 1; - return () => { - if (!activeService || activeService.queryClient !== queryClient) { - return; - } - activeService.refCount -= 1; - if (activeService.refCount === 0) { - stopActiveService(); - } - }; - } - - stopActiveService(); - needsProviderInvalidation = false; - const queryInvalidationThrottler = new Throttler( - () => { - if (!needsProviderInvalidation) { - return; - } - needsProviderInvalidation = false; - emitProviderInvalidation(); - }, - { - wait: 100, - leading: false, - trailing: true, - }, - ); - const requestSavedEnvironmentSync = createSavedEnvironmentSyncScheduler(); - - maybeCreatePrimaryEnvironmentConnection(); - - const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => { - if (!hasSavedEnvironmentRegistryHydrated()) { - return; - } - void requestSavedEnvironmentSync(); - }); - - void waitForSavedEnvironmentRegistryHydration() - .then(() => requestSavedEnvironmentSync()) - .catch(() => undefined); - - const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects(); - - activeService = { - queryClient, - queryInvalidationThrottler, - refCount: 1, - stop: () => { - unsubscribeSavedEnvironments(); - unsubscribeBrowserResumeReconnects(); - queryInvalidationThrottler.cancel(); - }, - }; - - return () => { - if (!activeService || activeService.queryClient !== queryClient) { - return; - } - activeService.refCount -= 1; - if (activeService.refCount === 0) { - stopActiveService(); - } - }; -} - -export async function resetEnvironmentServiceForTests(): Promise { - stopActiveService(); - lastBrowserHiddenAt = null; - lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; - lastAppliedProjectionVersionByEnvironment.clear(); - pendingSavedEnvironmentConnections.clear(); - savedEnvironmentConnectionAttempts.clear(); - for (const key of Array.from(threadDetailSubscriptions.keys())) { - disposeThreadDetailSubscriptionByKey(key); - } - for (const unsubscribe of terminalMetadataSubscriptions.values()) { - unsubscribe(); - } - terminalMetadataSubscriptions.clear(); - for (const unsubscribe of portDiscoverySubscriptions.values()) { - unsubscribe(); - } - portDiscoverySubscriptions.clear(); - usePortDiscoveryStore.getState().reset(); - terminalSessionManager.reset(); - await Promise.all( - [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), - ); -} diff --git a/apps/web/src/historyBootstrap.test.ts b/apps/web/src/historyBootstrap.test.ts index 2ccdb66016d..b4be13716ea 100644 --- a/apps/web/src/historyBootstrap.test.ts +++ b/apps/web/src/historyBootstrap.test.ts @@ -14,6 +14,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "hello", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, { @@ -21,6 +23,8 @@ describe("buildBootstrapInput", () => { role: "assistant", text: "world", createdAt: "2026-02-09T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:01.000Z", streaming: false, }, ], @@ -45,6 +49,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "first question with details", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, { @@ -52,6 +58,8 @@ describe("buildBootstrapInput", () => { role: "assistant", text: "first answer with details", createdAt: "2026-02-09T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:01.000Z", streaming: false, }, { @@ -59,6 +67,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "second question with details", createdAt: "2026-02-09T00:00:02.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:02.000Z", streaming: false, }, ], @@ -82,6 +92,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "old context", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, ], @@ -112,6 +124,8 @@ describe("buildBootstrapInput", () => { }, ], createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, ], diff --git a/apps/web/src/hooks/useCommitOnBlur.ts b/apps/web/src/hooks/useCommitOnBlur.ts index 43244762aa1..d1154fbb265 100644 --- a/apps/web/src/hooks/useCommitOnBlur.ts +++ b/apps/web/src/hooks/useCommitOnBlur.ts @@ -1,4 +1,4 @@ -import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { type ChangeEvent, type KeyboardEvent, useState } from "react"; /** * Buffer text input locally so keystrokes don't cause a settings-wide @@ -16,27 +16,21 @@ import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } fro * */ export function useCommitOnBlur(value: string, onCommit: (next: string) => void) { - const [draft, setDraft] = useState(value); - const focusedRef = useRef(false); - - useEffect(() => { - if (!focusedRef.current) { - setDraft(value); - } - }, [value]); + const [draft, setDraft] = useState(null); return { - value: draft, + value: draft ?? value, onChange: (event: ChangeEvent) => { setDraft(event.target.value); }, onFocus: () => { - focusedRef.current = true; + setDraft(value); }, onBlur: () => { - focusedRef.current = false; - if (draft !== value) { - onCommit(draft); + const next = draft ?? value; + setDraft(null); + if (next !== value) { + onCommit(next); } }, onKeyDown: (event: KeyboardEvent) => { diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts new file mode 100644 index 00000000000..ccac333cd48 --- /dev/null +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + ClipboardApiUnavailableError, + ClipboardWriteError, + writeTextToClipboard, +} from "./useCopyToClipboard"; + +describe("writeTextToClipboard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reports unavailable clipboard support with structural context", async () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + + const error = await writeTextToClipboard("plan contents", "plan").then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(ClipboardApiUnavailableError); + expect(error).toMatchObject({ + target: "plan", + }); + expect((error as Error).message).not.toContain("plan contents"); + }); + + it("preserves the exact clipboard failure without exposing copied contents", async () => { + const cause = new Error("browser clipboard failure"); + const writeText = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + const error = await writeTextToClipboard("secret clipboard contents", "error-message").then( + () => undefined, + (failure: unknown) => failure, + ); + + expect(writeText).toHaveBeenCalledWith("secret clipboard contents"); + expect(error).toBeInstanceOf(ClipboardWriteError); + expect(error).toMatchObject({ + target: "error-message", + cause, + }); + expect((error as Error).message).not.toContain("secret clipboard contents"); + }); + + it("keeps empty values as a no-op when clipboard support is available", async () => { + const writeText = vi.fn(); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index d1feb621159..0129f2d6593 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -1,11 +1,61 @@ import * as React from "react"; +import * as Schema from "effect/Schema"; + +export class ClipboardApiUnavailableError extends Schema.TaggedErrorClass()( + "ClipboardApiUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while copying ${this.target}.`; + } +} + +export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardWriteError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.target} to the clipboard.`; + } +} + +export async function writeTextToClipboard(value: string, target = "text") { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.writeText + ) { + throw new ClipboardApiUnavailableError({ + target, + }); + } + + if (!value) return false; + + try { + await navigator.clipboard.writeText(value); + return true; + } catch (cause) { + throw new ClipboardWriteError({ + target, + cause, + }); + } +} export function useCopyToClipboard({ timeout = 2000, + target = "text", onCopy, onError, }: { timeout?: number; + target?: string; onCopy?: (ctx: TContext) => void; onError?: (error: Error, ctx: TContext) => void; } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { @@ -13,22 +63,18 @@ export function useCopyToClipboard({ const timeoutIdRef = React.useRef(null); const onCopyRef = React.useRef(onCopy); const onErrorRef = React.useRef(onError); + const targetRef = React.useRef(target); const timeoutRef = React.useRef(timeout); onCopyRef.current = onCopy; onErrorRef.current = onError; + targetRef.current = target; timeoutRef.current = timeout; const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx); - return; - } - - if (!value) return; - - navigator.clipboard.writeText(value).then( - () => { + void writeTextToClipboard(value, targetRef.current).then( + (didCopy) => { + if (!didCopy) return; if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); } @@ -44,11 +90,8 @@ export function useCopyToClipboard({ } }, (error) => { - if (onErrorRef.current) { - onErrorRef.current(error, ctx); - } else { - console.error(error); - } + console.error(error); + onErrorRef.current?.(error, ctx); }, ); }, []); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e440497ba42..1b1c07b31c9 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,9 +1,17 @@ -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { + DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, + type ScopedProjectRef, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; -import { useShallow } from "zustand/react/shallow"; import { + markPromotedDraftThreadByRef, type DraftThreadEnvMode, type DraftThreadState, useComposerDraftStore, @@ -15,15 +23,16 @@ import { getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { selectProjectsAcrossEnvironments, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; +import { readThreadShell, useProjects, useServerConfigs, useThread } from "../state/entities"; +import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { resolveThreadRouteTarget } from "../threadRoutes"; -import { useUiStateStore } from "../uiStateStore"; -import { useSettings } from "./useSettings"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { useClientSettings } from "./useSettings"; -function useNewThreadState() { - const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); +export function useNewThreadHandler() { + const projects = useProjects(); + const serverConfigs = useServerConfigs(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -37,6 +46,7 @@ function useNewThreadState() { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; }, ): Promise => { const { @@ -53,39 +63,63 @@ function useNewThreadState() { candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); + const environmentSettings = + serverConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; + const hasStartFromOriginOption = options?.startFromOrigin !== undefined; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); + const storedDraftThreadRef = storedDraftThread + ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) + : null; + const reusableStoredDraftThread = + storedDraftThreadRef && readThreadShell(storedDraftThreadRef) !== null + ? null + : storedDraftThread; + if (storedDraftThreadRef && reusableStoredDraftThread === null) { + markPromotedDraftThreadByRef(storedDraftThreadRef); + } const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) : getDraftSession(currentRouteTarget.draftId) : null; - if (storedDraftThread) { + if (reusableStoredDraftThread) { return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.draftId, { + if ( + hasBranchOption || + hasWorktreePathOption || + hasEnvModeOption || + hasStartFromOriginOption + ) { + setDraftThreadContext(reusableStoredDraftThread.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); } - setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, { - threadId: storedDraftThread.threadId, - }); + setLogicalProjectDraftThreadId( + logicalProjectKey, + projectRef, + reusableStoredDraftThread.draftId, + { + threadId: reusableStoredDraftThread.threadId, + }, + ); if ( currentRouteTarget?.kind === "draft" && - currentRouteTarget.draftId === storedDraftThread.draftId + currentRouteTarget.draftId === reusableStoredDraftThread.draftId ) { return; } await router.navigate({ to: "/draft/$draftId", - params: { draftId: storedDraftThread.draftId }, + params: { draftId: reusableStoredDraftThread.draftId }, }); })(); } @@ -96,11 +130,17 @@ function useNewThreadState() { latestActiveDraftThread.logicalProjectKey === logicalProjectKey && latestActiveDraftThread.promotedTo == null ) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + if ( + hasBranchOption || + hasWorktreePathOption || + hasEnvModeOption || + hasStartFromOriginOption + ) { setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { @@ -111,6 +151,7 @@ function useNewThreadState() { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); return Promise.resolve(); } @@ -118,13 +159,20 @@ function useNewThreadState() { const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); + const initialEnvMode = options?.envMode ?? environmentSettings.defaultThreadEnvMode; return (async () => { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", + envMode: initialEnvMode, + startFromOrigin: + options?.startFromOrigin ?? + resolveNewDraftStartFromOrigin({ + envMode: initialEnvMode, + newWorktreesStartFromOrigin: environmentSettings.newWorktreesStartFromOrigin, + }), runtimeMode: DEFAULT_RUNTIME_MODE, }); applyStickyState(draftId); @@ -135,18 +183,10 @@ function useNewThreadState() { }); })(); }, - [getCurrentRouteTarget, projectGroupingSettings, router, projects], + [getCurrentRouteTarget, projectGroupingSettings, projects, router, serverConfigs], ); } -export function useNewThreadHandler() { - const handleNewThread = useNewThreadState(); - - return { - handleNewThread, - }; -} - export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); const routeTarget = useParams({ @@ -154,9 +194,7 @@ export function useHandleNewThread() { select: (params) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), - ); + const activeThread = useThread(routeThreadRef); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const activeDraftThread = useComposerDraftStore(() => routeTarget @@ -165,15 +203,19 @@ export function useHandleNewThread() { : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) : null, ); - const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projects = useProjects(); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], }); }, [projectOrder, projects]); - const handleNewThread = useNewThreadState(); + const handleNewThread = useNewThreadHandler(); return { activeDraftThread, diff --git a/apps/web/src/hooks/useLocalStorage.test.ts b/apps/web/src/hooks/useLocalStorage.test.ts new file mode 100644 index 00000000000..27627a36e4b --- /dev/null +++ b/apps/web/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,121 @@ +import * as Schema from "effect/Schema"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +async function loadWithStorage(storage: Storage) { + vi.stubGlobal("window", { localStorage: storage }); + vi.stubGlobal("localStorage", storage); + return import("./useLocalStorage"); +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +describe("local storage errors", () => { + it("preserves read failure context", async () => { + const cause = new Error("storage unavailable"); + const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( + createStorage({ + getItem: () => { + throw cause; + }, + }), + ); + + try { + getLocalStorageItem("read-key", Schema.String); + expect.unreachable("expected the read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "read-key", + cause, + }); + } + }); + + it("preserves decode failure context", async () => { + const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( + createStorage({ getItem: () => "not-json" }), + ); + + try { + getLocalStorageItem("decode-key", Schema.String); + expect.unreachable("expected decoding to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "decode", + storageKey: "decode-key", + cause: expect.anything(), + }); + } + }); + + it("preserves write failure context", async () => { + const cause = new Error("storage quota exceeded"); + const { LocalStorageOperationError, setLocalStorageItem } = await loadWithStorage( + createStorage({ + setItem: () => { + throw cause; + }, + }), + ); + + try { + setLocalStorageItem("write-key", "value", Schema.String); + expect.unreachable("expected the write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "write-key", + cause, + }); + } + }); + + it("preserves removal failure context", async () => { + const cause = new Error("storage unavailable"); + const { LocalStorageOperationError, removeLocalStorageItem } = await loadWithStorage( + createStorage({ + removeItem: () => { + throw cause; + }, + }), + ); + + try { + removeLocalStorageItem("remove-key"); + expect.unreachable("expected the removal to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "remove", + storageKey: "remove-key", + cause, + }); + } + }); +}); diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 93d26f66329..3099e73ff43 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -1,6 +1,19 @@ import * as Schema from "effect/Schema"; import * as Record from "effect/Record"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; + +export class LocalStorageOperationError extends Schema.TaggedErrorClass()( + "LocalStorageOperationError", + { + operation: Schema.Literals(["read", "decode", "encode", "update", "write", "remove", "notify"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} local storage item ${this.storageKey}.`; + } +} const isomorphicLocalStorage: Storage = typeof window !== "undefined" @@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage = }; })(); -const decode = (schema: Schema.Codec, value: string) => { - const decodeJson = Schema.decodeSync(Schema.fromJsonString(schema)); - return decodeJson(value); +const read = (key: string) => { + try { + return isomorphicLocalStorage.getItem(key); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); + } +}; + +const decode = (key: string, schema: Schema.Codec, value: string) => { + try { + return Schema.decodeSync(Schema.fromJsonString(schema))(value); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause }); + } }; -const encode = (schema: Schema.Codec, value: T) => { - const encodeJson = Schema.encodeSync(Schema.fromJsonString(schema)); - return encodeJson(value); +const encode = (key: string, schema: Schema.Codec, value: T) => { + try { + return Schema.encodeSync(Schema.fromJsonString(schema))(value); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause }); + } }; export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { - const item = isomorphicLocalStorage.getItem(key); - return item ? decode(schema, item) : null; + const item = read(key); + return item ? decode(key, schema, item) : null; }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { - const valueToSet = encode(schema, value); - isomorphicLocalStorage.setItem(key, valueToSet); + const valueToSet = encode(key, schema, value); + try { + isomorphicLocalStorage.setItem(key, valueToSet); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); + } }; export const removeLocalStorageItem = (key: string) => { - isomorphicLocalStorage.removeItem(key); + try { + isomorphicLocalStorage.removeItem(key); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); + } }; const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; @@ -51,11 +86,15 @@ interface LocalStorageChangeDetail { function dispatchLocalStorageChange(key: string) { if (typeof window === "undefined") return; - window.dispatchEvent( - new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { - detail: { key }, - }), - ); + try { + window.dispatchEvent( + new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { + detail: { key }, + }), + ); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause }); + } } export function useLocalStorage( @@ -63,85 +102,81 @@ export function useLocalStorage( initialValue: T, schema: Schema.Codec, ): [T, (value: T | ((val: T) => T)) => void] { - // Get the initial value from localStorage or use the provided initialValue - const [storedValue, setStoredValue] = useState(() => { + const getSnapshot = useCallback(() => { + try { + return read(key); + } catch (error) { + console.error("[LOCALSTORAGE] Could not read stored value.", error); + return null; + } + }, [key]); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === key) { + onStoreChange(); + } + }; + const handleLocalChange = (event: CustomEvent) => { + if (event.detail.key === key) { + onStoreChange(); + } + }; + + window.addEventListener("storage", handleStorageChange); + window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); + }; + }, + [key], + ); + + const serializedValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const storedValue = useMemo(() => { + if (serializedValue === null) { + return initialValue; + } try { - const item = getLocalStorageItem(key, schema); - return item ?? initialValue; + return decode(key, schema, serializedValue); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not decode stored value.", error); return initialValue; } - }); + }, [initialValue, key, schema, serializedValue]); - // Return a wrapped version of useState's setter function that persists the new value to localStorage const setValue = useCallback( (value: T | ((val: T) => T)) => { try { - setStoredValue((prev) => { - const valueToStore = typeof value === "function" ? (value as (val: T) => T)(prev) : value; - if (valueToStore === null) { - removeLocalStorageItem(key); - } else { - setLocalStorageItem(key, valueToStore, schema); + const currentValue = getLocalStorageItem(key, schema) ?? initialValue; + let valueToStore: T; + if (typeof value === "function") { + try { + valueToStore = (value as (val: T) => T)(currentValue); + } catch (cause) { + throw new LocalStorageOperationError({ + operation: "update", + storageKey: key, + cause, + }); } - // Dispatch event after state update completes to avoid nested state updates - queueMicrotask(() => dispatchLocalStorageChange(key)); - return valueToStore; - }); + } else { + valueToStore = value; + } + if (valueToStore === null) { + removeLocalStorageItem(key); + } else { + setLocalStorageItem(key, valueToStore, schema); + } + dispatchLocalStorageChange(key); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not update stored value.", error); } }, - [key, schema], + [initialValue, key, schema], ); - const prevKeyRef = useRef(key); - - // Re-sync from localStorage when key changes - useEffect(() => { - if (prevKeyRef.current !== key) { - prevKeyRef.current = key; - try { - const newValue = getLocalStorageItem(key, schema); - setStoredValue(newValue ?? initialValue); - } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); - } - } - }, [key, initialValue, schema]); - - // Listen for storage events from other tabs AND custom events from the same tab - useEffect(() => { - const syncFromStorage = () => { - try { - const newValue = getLocalStorageItem(key, schema); - setStoredValue(newValue ?? initialValue); - } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); - } - }; - - const handleStorageChange = (event: StorageEvent) => { - if (event.key === key) { - syncFromStorage(); - } - }; - - const handleLocalChange = (event: CustomEvent) => { - if (event.detail.key === key) { - syncFromStorage(); - } - }; - - window.addEventListener("storage", handleStorageChange); - window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); - - return () => { - window.removeEventListener("storage", handleStorageChange); - window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); - }; - }, [key, initialValue, schema]); - return [storedValue, setValue]; } diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 3552c82d9dc..08c067471f7 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -1,11 +1,5 @@ import * as Schema from "effect/Schema"; -import { - type PointerEvent as ReactPointerEvent, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from "react"; import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; @@ -61,15 +55,13 @@ export function useResizableWidth(options: UseResizableWidthOptions): { try { const stored = getLocalStorageItem(storageKey, WidthSchema); return clamp(stored ?? defaultWidth); - } catch { + } catch (error) { + console.error("Could not read persisted panel width.", error); return defaultWidth; } }); - // Re-clamp if min/max change at runtime (e.g. window resize narrows max). - useEffect(() => { - setWidth((current) => clamp(current)); - }, [clamp]); + const clampedWidth = clamp(width); const dragStateRef = useRef<{ pointerId: number; @@ -114,13 +106,13 @@ export function useResizableWidth(options: UseResizableWidthOptions): { dragStateRef.current = { pointerId: event.pointerId, startX: event.clientX, - startWidth: width, - pending: width, + startWidth: clampedWidth, + pending: clampedWidth, rafId: null, target, }; }, - [width], + [clampedWidth], ); const onPointerMove = useCallback( @@ -150,8 +142,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { // Commit once at drag-end to avoid 60Hz localStorage writes. try { setLocalStorageItem(storageKey, finalWidth, WidthSchema); - } catch { - // localStorage may be full / disabled; the in-memory state still wins. + } catch (error) { + console.error("Could not persist panel width.", error); } setWidth(finalWidth); }, @@ -170,7 +162,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { ); return { - width, + width: clampedWidth, handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, }; } diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts new file mode 100644 index 00000000000..7132c84c9d3 --- /dev/null +++ b/apps/web/src/hooks/useSettings.test.ts @@ -0,0 +1,37 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeEnvironmentSettings } from "./useSettings"; + +describe("mergeEnvironmentSettings", () => { + it("combines the selected environment's server settings with client preferences", () => { + const serverSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex_remote")]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + }, + }, + }; + const clientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + favorites: [ + { + provider: ProviderInstanceId.make("codex_remote"), + model: "gpt-5.4", + }, + ], + }; + + const settings = mergeEnvironmentSettings(serverSettings, clientSettings); + + expect(settings.providerInstances).toBe(serverSettings.providerInstances); + expect(settings.favorites).toBe(clientSettings.favorites); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 005c8ad82fc..514484d896a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -1,27 +1,34 @@ /** - * Unified settings hook. + * Environment-scoped settings hooks. * * Abstracts the split between server-authoritative settings (persisted in * `settings.json` on the server, fetched via `server.getConfig`) and * client-only settings (persisted in localStorage). * - * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to - * write. The hook transparently routes reads/writes to the correct backing - * store. + * Live server settings always require an environment id. Primary-environment + * access is intentionally named as such so environment-sensitive consumers + * cannot silently read the wrong server's settings. */ import { useCallback, useMemo, useSyncExternalStore } from "react"; -import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; import { type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, - DEFAULT_UNIFIED_SETTINGS, - UnifiedSettings, + type UnifiedSettings, } from "@t3tools/contracts/settings"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; -import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; +import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; +import { usePrimaryEnvironment } from "~/state/environments"; +import { useAtomCommand } from "~/state/use-atom-command"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -30,6 +37,7 @@ const clientSettingsHydrationListeners = new Set<() => void>(); let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; +let clientSettingsHydrationGeneration = 0; function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -88,16 +96,25 @@ async function hydrateClientSettings(): Promise { return clientSettingsHydrationPromise; } + const hydrationGeneration = clientSettingsHydrationGeneration; const nextHydration = (async () => { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); + if (hydrationGeneration !== clientSettingsHydrationGeneration) { + return; + } if (persistedSettings) { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { + operation: "hydrate", + ...safeErrorLogAttributes(error), + }); } finally { - setClientSettingsHydrated(true); + if (hydrationGeneration === clientSettingsHydrationGeneration) { + setClientSettingsHydrated(true); + } } })(); @@ -116,7 +133,10 @@ function persistClientSettings(settings: ClientSettings): void { void ensureLocalApi() .persistence.setClientSettings(settings) .catch((error) => { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error); + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { + operation: "persist", + ...safeErrorLogAttributes(error), + }); }); } @@ -145,11 +165,6 @@ function splitPatch(patch: Partial): { // ── Hooks ──────────────────────────────────────────────────────────── -/** - * Read merged settings. Selector narrows the subscription so components - * only re-render when the slice they care about changes. - */ - /** * Non-hook accessor for the current merged client settings snapshot. * Used by non-React code paths (e.g. runtime services) that need the latest @@ -167,66 +182,124 @@ export function useClientSettingsHydrated(): boolean { ); } -export function useSettings(selector?: (s: UnifiedSettings) => T): T { - const serverSettings = useServerSettings(); - const clientSettings = useSyncExternalStore( +function useClientSettingsValue(): ClientSettings { + return useSyncExternalStore( subscribeClientSettings, getClientSettingsSnapshot, () => DEFAULT_CLIENT_SETTINGS, ); +} + +export function mergeEnvironmentSettings( + serverSettings: ServerSettings, + clientSettings: ClientSettings, +): UnifiedSettings { + return { ...serverSettings, ...clientSettings }; +} + +function useMergedSettings( + serverSettings: ServerSettings, + selector: ((settings: UnifiedSettings) => T) | undefined, +): T { + const clientSettings = useClientSettingsValue(); const merged = useMemo( - () => ({ - ...serverSettings, - ...clientSettings, - }), + () => mergeEnvironmentSettings(serverSettings, clientSettings), [clientSettings, serverSettings], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } +export function useClientSettings( + selector?: (settings: ClientSettings) => T, +): T { + const settings = useClientSettingsValue(); + return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); +} + +/** Read current settings for one environment, merged with client-local preferences. */ +export function useEnvironmentSettings( + environmentId: EnvironmentId, + selector?: (settings: UnifiedSettings) => T, +): T { + const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); + return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); +} + +/** Primary-only settings access for the settings UI and other explicitly global surfaces. */ +export function usePrimarySettings( + selector?: (settings: UnifiedSettings) => T, +): T { + return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); +} + /** * Returns an updater that routes each key to the correct backing store. * * Server keys are optimistically patched in atom-backed server state, then * persisted via RPC. Client keys go through client persistence. */ -export function useUpdateSettings() { - const updateSettings = useCallback((patch: Partial) => { - const { serverPatch, clientPatch } = splitPatch(patch); - - if (Object.keys(serverPatch).length > 0) { - const currentServerConfig = getServerConfig(); - if (currentServerConfig) { - applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); +function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { + const persistServerSettings = useAtomCommand( + serverEnvironment.updateSettings, + "server settings update", + ); + const updateSettings = useCallback( + (patch: Partial) => { + const { serverPatch, clientPatch } = splitPatch(patch); + + if (Object.keys(serverPatch).length > 0) { + if (environmentId) { + void persistServerSettings({ + environmentId, + input: { patch: serverPatch }, + }); + } } - // Fire-and-forget RPC — push will reconcile on success - void ensureLocalApi().server.updateSettings(serverPatch); - } - if (Object.keys(clientPatch).length > 0) { - persistClientSettings({ - ...getClientSettingsSnapshot(), - ...clientPatch, - }); - } - }, []); + if (Object.keys(clientPatch).length > 0) { + persistClientSettings({ + ...getClientSettingsSnapshot(), + ...clientPatch, + }); + } + }, + [environmentId, persistServerSettings], + ); - const resetSettings = useCallback(() => { - updateSettings(DEFAULT_UNIFIED_SETTINGS); - }, [updateSettings]); + return updateSettings; +} - return { - updateSettings, - resetSettings, - }; +export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { + return useUpdateSettingsTarget(environmentId); +} + +export function useUpdatePrimarySettings() { + return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null); +} + +export function useUpdateClientSettings() { + return useCallback((patch: ClientSettingsPatch) => { + persistClientSettings({ + ...getClientSettingsSnapshot(), + ...patch, + }); + }, []); } export function __resetClientSettingsPersistenceForTests(): void { + clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrated = false; clientSettingsHydrationPromise = null; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); } + +export function __setClientSettingsForTests(settings: ClientSettings): void { + clientSettingsHydrationGeneration += 1; + clientSettingsSnapshot = settings; + clientSettingsHydrated = true; + clientSettingsHydrationPromise = null; +} diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts new file mode 100644 index 00000000000..6c814e30165 --- /dev/null +++ b/apps/web/src/hooks/useTheme.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +afterEach(() => { + vi.doUnmock("react"); + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("theme failure handling", () => { + it("preserves exact storage causes and operation context", async () => { + const readCause = new Error("storage read blocked"); + const writeCause = new Error("storage quota exceeded"); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw readCause; + }, + setItem: () => { + throw writeCause; + }, + }), + }); + + const { readThemePreference, ThemeStorageError, writeThemePreference } = + await import("./useTheme"); + + try { + readThemePreference(); + expect.unreachable("expected the theme read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "t3code:theme", + cause: readCause, + }); + } + + try { + writeThemePreference("dark"); + expect.unreachable("expected the theme write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "t3code:theme", + theme: "dark", + cause: writeCause, + }); + } + }); + + it("falls back during initial theme application and logs only safe attributes", async () => { + const cause = new Error("private browsing storage failure"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw cause; + }, + }), + matchMedia: () => ({ matches: false }), + }); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + }, + }); + + await expect(import("./useTheme")).resolves.toBeDefined(); + + expect(errorLog).toHaveBeenCalledWith( + "Failed to read theme preference for t3code:theme.", + expect.objectContaining({ + operation: "read", + storageKey: "t3code:theme", + errorTag: "ThemeStorageError", + }), + ); + const attributes = errorLog.mock.calls[0]?.[1]; + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + }); + + it("retries a failed storage read only after a relevant storage event", async () => { + const cause = new Error("persistent storage failure"); + const getItem = vi.fn(() => { + throw cause; + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + let readSnapshot: (() => unknown) | undefined; + let subscribeToTheme: ((listener: () => void) => () => void) | undefined; + let storageHandler: ((event: StorageEvent) => void) | undefined; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribeToTheme = subscribe; + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: createStorage({ getItem }), + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + + const { useTheme } = await import("./useTheme"); + useTheme(); + readSnapshot?.(); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(1); + expect(errorLog).toHaveBeenCalledTimes(1); + + const unsubscribe = subscribeToTheme?.(() => undefined); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); + + it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => { + const cause = new Error("desktop IPC unavailable"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const setTheme = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", { desktopBridge: { setTheme } }); + + const { DesktopThemeSyncError, syncDesktopTheme, syncDesktopThemePreference } = + await import("./useTheme"); + + const error = await syncDesktopThemePreference({ setTheme }, "dark").then( + () => undefined, + (failure: unknown) => failure, + ); + expect(error).toBeInstanceOf(DesktopThemeSyncError); + expect(error).toMatchObject({ theme: "dark", cause }); + + setTheme.mockClear(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + + expect(setTheme).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledWith( + "Failed to sync the dark theme to the desktop shell.", + expect.objectContaining({ + theme: "dark", + errorTag: "DesktopThemeSyncError", + }), + ); + for (const [, attributes] of errorLog.mock.calls) { + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + } + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 78d063a9609..bdaf37f099d 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,11 +1,17 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; -type Theme = "light" | "dark" | "system"; +const ThemePreference = Schema.Literals(["light", "dark", "system"]); +type Theme = typeof ThemePreference.Type; type ThemeSnapshot = { theme: Theme; systemDark: boolean; }; +type DesktopThemeBridge = Pick; + const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { @@ -15,29 +21,109 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; +export class ThemeStorageError extends Schema.TaggedErrorClass()( + "ThemeStorageError", + { + operation: Schema.Literals(["read", "write"]), + storageKey: Schema.String, + theme: Schema.optional(ThemePreference), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; + } +} + +export const isThemeStorageError = Schema.is(ThemeStorageError); + +export class DesktopThemeSyncError extends Schema.TaggedErrorClass()( + "DesktopThemeSyncError", + { + theme: ThemePreference, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sync the ${this.theme} theme to the desktop shell.`; + } +} + +export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); + let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; +let lastAppliedTheme: ThemeSnapshot | null = null; +let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() { for (const listener of listeners) listener(); } -function hasThemeStorage() { - return typeof window !== "undefined" && typeof localStorage !== "undefined"; -} - function getSystemDark() { - return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches; + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(MEDIA_QUERY).matches + ); } -function getStored(): Theme { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; - const raw = localStorage.getItem(STORAGE_KEY); +export function readThemePreference(): Theme { + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; + let raw: string | null; + try { + raw = window.localStorage.getItem(STORAGE_KEY); + } catch (cause) { + throw new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + } if (raw === "light" || raw === "dark" || raw === "system") return raw; return DEFAULT_THEME_SNAPSHOT.theme; } +export function writeThemePreference(theme: Theme): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, theme); + themeStorageReadFailure = null; + } catch (cause) { + throw new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme, + cause, + }); + } +} + +function getStored(): Theme { + if (themeStorageReadFailure !== null) { + return DEFAULT_THEME_SNAPSHOT.theme; + } + try { + return readThemePreference(); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + themeStorageReadFailure = error; + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + ...safeErrorLogAttributes(error), + }); + return DEFAULT_THEME_SNAPSHOT.theme; + } +} + function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); if (element) { @@ -89,11 +175,18 @@ export function syncBrowserChromeTheme() { function applyTheme(theme: Theme, suppressTransitions = false) { if (typeof document === "undefined" || typeof window === "undefined") return; + const systemDark = theme === "system" ? getSystemDark() : false; + if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { + syncDesktopTheme(theme); + return; + } + if (suppressTransitions) { document.documentElement.classList.add("no-transitions"); } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); + const isDark = theme === "dark" || (theme === "system" && systemDark); document.documentElement.classList.toggle("dark", isDark); + lastAppliedTheme = { theme, systemDark }; syncBrowserChromeTheme(); syncDesktopTheme(theme); if (suppressTransitions) { @@ -106,7 +199,18 @@ function applyTheme(theme: Theme, suppressTransitions = false) { } } -function syncDesktopTheme(theme: Theme) { +export async function syncDesktopThemePreference( + bridge: DesktopThemeBridge, + theme: Theme, +): Promise { + try { + await bridge.setTheme(theme); + } catch (cause) { + throw new DesktopThemeSyncError({ theme, cause }); + } +} + +export function syncDesktopTheme(theme: Theme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { @@ -114,7 +218,14 @@ function syncDesktopTheme(theme: Theme) { } lastDesktopTheme = theme; - void bridge.setTheme(theme).catch(() => { + void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { + const error = isDesktopThemeSyncError(cause) + ? cause + : new DesktopThemeSyncError({ theme, cause }); + console.error(error.message, { + theme: error.theme, + ...safeErrorLogAttributes(error), + }); if (lastDesktopTheme === theme) { lastDesktopTheme = null; } @@ -122,12 +233,12 @@ function syncDesktopTheme(theme: Theme) { } // Apply immediately on module load to prevent flash -if (typeof document !== "undefined" && hasThemeStorage()) { +if (typeof document !== "undefined" && typeof window !== "undefined") { applyTheme(getStored()); } function getSnapshot(): ThemeSnapshot { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; const theme = getStored(); const systemDark = theme === "system" ? getSystemDark() : false; @@ -148,16 +259,17 @@ function subscribe(listener: () => void): () => void { listeners.push(listener); // Listen for system preference changes - const mq = window.matchMedia(MEDIA_QUERY); + const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; const handleChange = () => { if (getStored() === "system") applyTheme("system", true); emitChange(); }; - mq.addEventListener("change", handleChange); + mq?.addEventListener("change", handleChange); // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY) { + themeStorageReadFailure = null; applyTheme(getStored(), true); emitChange(); } @@ -166,7 +278,7 @@ function subscribe(listener: () => void): () => void { return () => { listeners = listeners.filter((l) => l !== listener); - mq.removeEventListener("change", handleChange); + mq?.removeEventListener("change", handleChange); window.removeEventListener("storage", handleStorage); }; } @@ -179,8 +291,26 @@ export function useTheme() { theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { - if (!hasThemeStorage()) return; - localStorage.setItem(STORAGE_KEY, next); + if (typeof window === "undefined") return; + try { + writeThemePreference(next); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme: next, + cause, + }); + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + theme: next, + ...safeErrorLogAttributes(error), + }); + return; + } applyTheme(next, true); emitChange(); }, []); diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts new file mode 100644 index 00000000000..c5385211591 --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -0,0 +1,19 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadArchiveBlockedError } from "./useThreadActions"; + +describe("ThreadArchiveBlockedError", () => { + it("keeps the blocked thread context with the fixed message", () => { + const error = new ThreadArchiveBlockedError({ + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }); + + expect(error).toMatchObject({ + environmentId: "environment-1", + threadId: "thread-1", + }); + expect(error.message).toBe("Cannot archive a running thread."); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7325a96913d..07655ad30d7 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,38 +1,71 @@ -import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; +import { terminalEnvironment } from "../state/terminal"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; -import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; -import { invalidateSourceControlState } from "../lib/sourceControlActions"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; -import { newCommandId } from "../lib/utils"; import { readLocalApi } from "../localApi"; -import { - selectProjectByRef, - selectThreadByRef, - selectThreadsForEnvironment, - useStore, -} from "../store"; +import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { useSettings } from "./useSettings"; +import { useClientSettings } from "./useSettings"; +import { useAtomCommand } from "../state/use-atom-command"; + +export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadArchiveBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "Cannot archive a running thread."; + } +} export function useThreadActions() { - const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); - const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); + const closeTerminal = useAtomCommand(terminalEnvironment.close); + const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { + reportFailure: false, + }); + const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, { + reportFailure: false, + }); + const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { + reportFailure: false, + }); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); + const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { + reportFailure: false, + }); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { + reportFailure: false, + }); + const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); + const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); const router = useRouter(); - const { handleNewThread } = useNewThreadHandler(); + const handleNewThread = useNewThreadHandler(); // Keep a ref so archiveThread can call handleNewThread without appearing in // its dependency array — handleNewThread is inherently unstable (depends on // the projects list) and would otherwise cascade new references into every @@ -41,8 +74,7 @@ export function useThreadActions() { handleNewThreadRef.current = handleNewThread; const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { - const state = useStore.getState(); - const thread = selectThreadByRef(state, target); + const thread = readThreadShell(target); if (!thread) { return null; } @@ -58,65 +90,83 @@ export function useThreadActions() { const archiveThread = useCallback( async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const resolved = resolveThreadTarget(target); - if (!resolved) return; + if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { - throw new Error("Cannot archive a running thread."); + return AsyncResult.failure( + Cause.fail( + new ThreadArchiveBlockedError({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }), + ), + ); } const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveCommand = api.orchestration.dispatchCommand({ - type: "thread.archive", - commandId: newCommandId(), - threadId: threadRef.threadId, + const archiveResult = await archiveThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, }); + if (archiveResult._tag === "Failure") { + return archiveResult; + } if (shouldNavigateToDraft) { - await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); + const navigationResult = await settlePromise(() => + handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + return archiveResult; } - await archiveCommand; refreshArchivedThreadsForEnvironment(threadRef.environmentId); + return archiveResult; }, - [getCurrentRouteThreadRef, resolveThreadTarget], + [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], ); - const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; - await api.orchestration.dispatchCommand({ - type: "thread.unarchive", - commandId: newCommandId(), - threadId: target.threadId, - }); - refreshArchivedThreadsForEnvironment(target.environmentId); - }, []); + const unarchiveThread = useCallback( + async (target: ScopedThreadRef) => { + const result = await unarchiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Success") { + refreshArchivedThreadsForEnvironment(target.environmentId); + } + return result; + }, + [unarchiveThreadMutation], + ); const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: target.threadId, + const result = await deleteThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, }); - refreshArchivedThreadsForEnvironment(target.environmentId); - return; + if (result._tag === "Success") { + refreshArchivedThreadsForEnvironment(target.environmentId); + } + return result; } const { thread, threadRef } = resolved; - const state = useStore.getState(); - const threads = selectThreadsForEnvironment(state, threadRef.environmentId); - const threadProject = selectProjectByRef(state, { + const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }); + const threadProject = readProject({ environmentId: threadRef.environmentId, projectId: thread.projectId, }); @@ -140,37 +190,38 @@ export function useThreadActions() { const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); - const shouldDeleteWorktree = - canDeleteWorktree && - localApi && - (await localApi.dialogs.confirm( - [ - "This thread is the only one linked to this worktree:", - displayWorktreePath ?? orphanedWorktreePath, - "", - "Delete the worktree too?", - ].join("\n"), - )); - - if (thread.session && thread.session.status !== "closed") { - await api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: threadRef.threadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); + let shouldDeleteWorktree = false; + if (canDeleteWorktree && localApi) { + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm( + [ + "This thread is the only one linked to this worktree:", + displayWorktreePath ?? orphanedWorktreePath, + "", + "Delete the worktree too?", + ].join("\n"), + ), + ); + if (confirmationResult._tag === "Failure") { + return confirmationResult; + } + shouldDeleteWorktree = confirmationResult.value; } - try { - await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); - } catch { - // Terminal may already be closed. + if (thread.session && thread.session.status !== "stopped") { + await stopThreadSession({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); } + await closeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, deleteHistory: true }, + }); + const deletedThreadIds = deletedIds ?? new Set(); const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToFallback = @@ -182,11 +233,13 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, }); - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadRef.threadId, + const deleteResult = await deleteThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, }); + if (deleteResult._tag === "Failure") { + return deleteResult; + } refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( @@ -197,44 +250,71 @@ export function useThreadActions() { if (shouldNavigateToFallback) { if (fallbackThreadId) { - const fallbackThread = selectThreadByRef( - useStore.getState(), + const fallbackThread = readThreadShell( scopeThreadRef(threadRef.environmentId, fallbackThreadId), ); if (fallbackThread) { - await router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams( - scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), - ), - replace: true, - }); + const navigationResult = await settlePromise(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), + ), + replace: true, + }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } else { - await router.navigate({ to: "/", replace: true }); + const navigationResult = await settlePromise(() => + router.navigate({ to: "/", replace: true }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } } else { - await router.navigate({ to: "/", replace: true }); + const navigationResult = await settlePromise(() => + router.navigate({ to: "/", replace: true }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { - return; + return deleteResult; } - try { - await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ - cwd: threadProject.cwd, + const removeResult = await removeWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: threadProject.workspaceRoot, path: orphanedWorktreePath, force: true, - }); - await invalidateSourceControlState({ - environmentId: threadRef.environmentId, - }); - } catch (error) { + }, + }); + const refreshResult = + removeResult._tag === "Success" + ? await refreshVcsStatus({ + environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot }, + }) + : null; + const cleanupFailure = + removeResult._tag === "Failure" + ? removeResult + : refreshResult?._tag === "Failure" + ? refreshResult + : null; + if (cleanupFailure) { + const error = squashAtomCommandFailure(cleanupFailure); const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { threadId: threadRef.threadId, - projectCwd: threadProject.cwd, + projectCwd: threadProject.workspaceRoot, worktreePath: orphanedWorktreePath, error, }); @@ -245,48 +325,61 @@ export function useThreadActions() { description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, }), ); + return cleanupFailure; } + return deleteResult; }, [ clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, + closeTerminal, + deleteThreadMutation, getCurrentRouteThreadRef, + refreshVcsStatus, + removeWorktree, router, resolveThreadTarget, sidebarThreadSortOrder, + stopThreadSession, ], ); const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); if (confirmThreadDelete && localApi) { const title = resolved?.thread.title ?? "this thread"; - const confirmed = await localApi.dialogs.confirm( - [ - `Delete thread "${title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm( + [ + `Delete thread "${title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), ); - if (!confirmed) { - return; + if (confirmationResult._tag === "Failure") { + return confirmationResult; + } + if (!confirmationResult.value) { + return AsyncResult.success(undefined); } } - await deleteThread(target); + return deleteThread(target); }, [confirmThreadDelete, deleteThread, resolveThreadTarget], ); - return { - archiveThread, - unarchiveThread, - deleteThread, - confirmAndDeleteThread, - }; + return useMemo( + () => ({ + archiveThread, + unarchiveThread, + deleteThread, + confirmAndDeleteThread, + }), + [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + ); } diff --git a/apps/web/src/hooks/useTurnDiffSummaries.ts b/apps/web/src/hooks/useTurnDiffSummaries.ts index 2bf72c96cca..f51acc15cc0 100644 --- a/apps/web/src/hooks/useTurnDiffSummaries.ts +++ b/apps/web/src/hooks/useTurnDiffSummaries.ts @@ -1,13 +1,13 @@ import { useMemo } from "react"; import { inferCheckpointTurnCountByTurnId } from "../session-logic"; -import type { Thread } from "../types"; +import type { Thread, TurnDiffSummary } from "../types"; -export function useTurnDiffSummaries(activeThread: Thread | undefined) { - const turnDiffSummaries = useMemo(() => { +export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { + const turnDiffSummaries = useMemo>(() => { if (!activeThread) { return []; } - return activeThread.turnDiffSummaries; + return activeThread.checkpoints; }, [activeThread]); const inferredCheckpointTurnCountByTurnId = useMemo( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9048e2074ed..3ea1364156f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -5,15 +5,27 @@ @custom-variant wco (&:is(.wco, .wco *)); :root { + --app-scrollbar-width: 6px; --workspace-topbar-height: 52px; --workspace-controls-top: 0px; + --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem); --workspace-native-controls-inset: 0px; + --workspace-titlebar-control-size: 1.75rem; + --workspace-titlebar-control-gap: 0.75rem; +} + +[data-slot="sidebar-wrapper"] { + --workspace-titlebar-content-left: calc( + var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + + var(--workspace-titlebar-control-gap) + ); } .wco { --workspace-topbar-height: env(titlebar-area-height, 52px); --workspace-controls-top: env(titlebar-area-y, 0px); + --workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem); --workspace-controls-right: calc( 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem ); @@ -90,6 +102,28 @@ } @layer components { + .sidebar-brand { + display: none; + } + + .sidebar-brand-stage { + display: none; + } + + @media (min-width: 48rem) { + @container sidebar-header (min-width: 13.5rem) { + .sidebar-brand { + display: flex; + } + } + + @container sidebar-header (min-width: 15.75rem) { + .sidebar-brand-stage { + display: inline-flex; + } + } + } + .workspace-topbar { display: flex; height: var(--workspace-topbar-height); @@ -111,6 +145,58 @@ .surface-subheader { @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background; } + + .chat-composer-horizontal-inset { + padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem); + padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); + } + + .chat-composer-glass, + .chat-composer-lower-chrome { + background: color-mix(in srgb, var(--card) 20%, transparent); + } + + .chat-composer-glass { + box-shadow: + 0 18px 48px -20px rgb(0 0 0 / 28%), + 0 4px 14px -7px rgb(0 0 0 / 22%); + } + + .chat-composer-shared-blur { + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + } + + .dark .chat-composer-glass, + .dark .chat-composer-lower-chrome { + background: color-mix(in srgb, var(--card) 45%, transparent); + } + + .dark .chat-composer-glass { + box-shadow: + 0 18px 48px -20px rgb(0 0 0 / 60%), + 0 4px 14px -7px rgb(0 0 0 / 40%); + } + + .chat-composer-lower-chrome { + margin-top: -1px; + margin-inline-end: var(--app-scrollbar-width); + padding-top: 1px; + } + + @media (min-width: 40rem) { + .chat-composer-horizontal-inset { + padding-inline-start: calc(env(safe-area-inset-left) + 1.25rem); + padding-inline-end: calc(env(safe-area-inset-right) + 1.25rem); + } + } + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + .chat-composer-glass, + .chat-composer-lower-chrome { + background: var(--card); + } + } } /* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering. @@ -266,7 +352,7 @@ code { /* Scrollbar styling */ ::-webkit-scrollbar { - width: 6px; + width: var(--app-scrollbar-width); } ::-webkit-scrollbar-track { @@ -689,7 +775,8 @@ label:has(> select#reasoning-effort) select { background: color-mix(in srgb, var(--background) 94%, var(--card)); } -.diff-render-file { +.diff-render-file, +.diff-render-surface > diffs-container { border: 1px solid var(--border); border-radius: 0.5rem; overflow: clip; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d4fc945cc04..c0d326edd55 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -85,6 +85,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { } const DEFAULT_BINDINGS = compile([ + { shortcut: modShortcut("b"), command: "sidebar.toggle" }, { shortcut: modShortcut("j"), command: "terminal.toggle" }, { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, { @@ -312,6 +313,10 @@ describe("shortcutLabelForCommand", () => { }); it("returns effective labels for non-terminal commands", () => { + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", + ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index f465b620a28..2d52383c02c 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -1,23 +1,29 @@ import { useAtomValue } from "@effect/atom-react"; import { type ArchivedSnapshotEntry, - createArchivedThreadsManager, + createArchivedThreadSnapshotsAtomFamily, makeArchivedThreadsEnvironmentKey, - readArchivedThreadsSnapshotState, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useMemo } from "react"; -import { readEnvironmentApi } from "../environmentApi"; +import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; -const archivedThreadsManager = createArchivedThreadsManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, +function archivedSnapshotAtom(environmentId: EnvironmentId) { + return orchestrationEnvironment.archivedShellSnapshot({ + environmentId, + input: {}, + }); +} + +const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: archivedSnapshotAtom, + labelPrefix: "web:archived-thread-snapshots", }); export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { - archivedThreadsManager.refreshForEnvironment(environmentId); + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); } export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { @@ -30,14 +36,15 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray makeArchivedThreadsEnvironmentKey(environmentIds), [environmentIds], ); - const atom = archivedThreadsManager.getAtom(environmentKey); - const result = useAtomValue(atom); + const result = useAtomValue(archivedSnapshotsAtom(environmentKey)); const refresh = useCallback(() => { - archivedThreadsManager.refresh(environmentIds); + for (const environmentId of environmentIds) { + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); + } }, [environmentIds]); return { - ...readArchivedThreadsSnapshotState(result), + ...result, refresh, }; } diff --git a/apps/web/src/lib/baseRefChoices.test.ts b/apps/web/src/lib/baseRefChoices.test.ts new file mode 100644 index 00000000000..90f84d900f4 --- /dev/null +++ b/apps/web/src/lib/baseRefChoices.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { VcsRef } from "@t3tools/contracts"; +import { buildBaseRefChoices, filterBaseRefChoices } from "./baseRefChoices"; + +function ref(name: string, remoteName?: string): VcsRef { + return { + name, + current: false, + isDefault: false, + isRemote: remoteName !== undefined, + ...(remoteName ? { remoteName } : {}), + worktreePath: null, + }; +} + +describe("buildBaseRefChoices", () => { + it("pairs matching local and remote branches and prefers origin", () => { + const choices = buildBaseRefChoices( + [ref("main")], + [ref("upstream/main", "upstream"), ref("origin/main", "origin")], + ); + + expect(choices).toEqual([ + expect.objectContaining({ + label: "main", + local: expect.objectContaining({ name: "main" }), + remote: expect.objectContaining({ name: "origin/main" }), + }), + expect.objectContaining({ + label: "upstream/main", + local: null, + remote: expect.objectContaining({ name: "upstream/main" }), + }), + ]); + }); +}); + +describe("filterBaseRefChoices", () => { + it("filters stale server results against the current query", () => { + const choices = buildBaseRefChoices( + [ref("main"), ref("feature/search")], + [ref("origin/main", "origin"), ref("origin/feature/search", "origin")], + ); + + expect(filterBaseRefChoices(choices, "SEARCH").map((choice) => choice.label)).toEqual([ + "feature/search", + ]); + expect(filterBaseRefChoices(choices, "origin/main").map((choice) => choice.label)).toEqual([ + "main", + ]); + }); +}); diff --git a/apps/web/src/lib/baseRefChoices.ts b/apps/web/src/lib/baseRefChoices.ts new file mode 100644 index 00000000000..2be010040a3 --- /dev/null +++ b/apps/web/src/lib/baseRefChoices.ts @@ -0,0 +1,61 @@ +import type { VcsRef } from "@t3tools/contracts"; + +export interface BaseRefChoice { + readonly id: string; + readonly label: string; + readonly local: VcsRef | null; + readonly remote: VcsRef | null; +} + +function remoteBranchName(ref: VcsRef): string { + if (ref.remoteName && ref.name.startsWith(`${ref.remoteName}/`)) { + return ref.name.slice(ref.remoteName.length + 1); + } + return ref.name; +} + +export function buildBaseRefChoices( + localRefs: ReadonlyArray, + remoteRefs: ReadonlyArray, +): ReadonlyArray { + const unusedRemoteRefs = new Set(remoteRefs); + const pairedChoices = localRefs.map((local) => { + const matches = remoteRefs.filter( + (remote) => unusedRemoteRefs.has(remote) && remoteBranchName(remote) === local.name, + ); + const remote = + matches.find((candidate) => candidate.remoteName === "origin") ?? matches[0] ?? null; + if (remote) unusedRemoteRefs.delete(remote); + return { + id: `local:${local.name}`, + label: local.name, + local, + remote, + }; + }); + + const remoteOnlyChoices = remoteRefs + .filter((remote) => unusedRemoteRefs.has(remote)) + .map((remote) => ({ + id: `remote:${remote.name}`, + label: remote.name, + local: null, + remote, + })); + + return [...pairedChoices, ...remoteOnlyChoices]; +} + +export function filterBaseRefChoices( + choices: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (normalizedQuery.length === 0) return choices; + return choices.filter( + (choice) => + choice.label.toLocaleLowerCase().includes(normalizedQuery) || + choice.local?.name.toLocaleLowerCase().includes(normalizedQuery) === true || + choice.remote?.name.toLocaleLowerCase().includes(normalizedQuery) === true, + ); +} diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 56c7508f9e5..2b1d7b09b9f 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,8 +1,9 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, + resolveNewDraftStartFromOrigin, startNewLocalThreadFromContext, startNewThreadFromContext, type ChatThreadActionContext, @@ -17,13 +18,27 @@ function createContext(overrides: Partial = {}): ChatTh activeDraftThread: null, activeThread: undefined, defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID), - defaultThreadEnvMode: "local", handleNewThread: async () => {}, ...overrides, }; } describe("chatThreadActions", () => { + it("only applies the start-from-origin default to new worktree drafts", () => { + expect( + resolveNewDraftStartFromOrigin({ + envMode: "worktree", + newWorktreesStartFromOrigin: true, + }), + ).toBe(true); + expect( + resolveNewDraftStartFromOrigin({ + envMode: "local", + newWorktreesStartFromOrigin: true, + }), + ).toBe(false); + }); + it("prefers the active draft thread project when resolving thread actions", () => { const projectRef = resolveThreadActionProjectRef( createContext({ @@ -33,6 +48,7 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, }, }), ); @@ -61,6 +77,7 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, }, handleNewThread, }), @@ -71,26 +88,49 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, }); }); - it("starts a local thread with the configured default env mode", async () => { + it("preserves an explicitly disabled origin base in contextual thread options", async () => { const handleNewThread = vi.fn(async () => {}); - const didStart = await startNewLocalThreadFromContext( + await startNewThreadFromContext( createContext({ - defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), - defaultThreadEnvMode: "worktree", + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + startFromOrigin: false, + }, handleNewThread, }), ); - expect(didStart).toBe(true); expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/refactor", + worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: false, }); }); + it("delegates the target environment defaults to the new-thread handler", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewLocalThreadFromContext( + createContext({ + defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + it("does not start a thread when there is no project context", async () => { const handleNewThread = vi.fn(async () => {}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 39826e8af3d..4f30885610a 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; import type { DraftThreadEnvMode } from "../composerDraftStore"; @@ -11,6 +11,7 @@ interface ThreadContextLike { interface DraftThreadContextLike extends ThreadContextLike { envMode: DraftThreadEnvMode; + startFromOrigin: boolean; } interface NewThreadHandler { @@ -20,6 +21,7 @@ interface NewThreadHandler { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; }, ): Promise; } @@ -30,10 +32,16 @@ export interface ChatThreadActionContext { readonly activeDraftThread: DraftThreadContextLike | null; readonly activeThread: ThreadContextLike | undefined; readonly defaultProjectRef: ScopedProjectRef | null; - readonly defaultThreadEnvMode: DraftThreadEnvMode; readonly handleNewThread: NewThreadHandler; } +export function resolveNewDraftStartFromOrigin(input: { + envMode: DraftThreadEnvMode; + newWorktreesStartFromOrigin: boolean; +}): boolean { + return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null { @@ -57,12 +65,9 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre envMode: context.activeDraftThread?.envMode ?? (context.activeThread?.worktreePath ? "worktree" : "local"), - }; -} - -function buildDefaultThreadOptions(context: ChatThreadActionContext): NewThreadOptions { - return { - envMode: context.defaultThreadEnvMode, + ...(context.activeDraftThread + ? { startFromOrigin: context.activeDraftThread.startFromOrigin } + : {}), }; } @@ -93,6 +98,6 @@ export async function startNewLocalThreadFromContext( return false; } - await context.handleNewThread(projectRef, buildDefaultThreadOptions(context)); + await context.handleNewThread(projectRef); return true; } diff --git a/apps/web/src/lib/checkpointDiffState.ts b/apps/web/src/lib/checkpointDiffState.ts index afd38b84e5d..067e22d51df 100644 --- a/apps/web/src/lib/checkpointDiffState.ts +++ b/apps/web/src/lib/checkpointDiffState.ts @@ -1,63 +1,18 @@ -import { useAtomValue } from "@effect/atom-react"; import { type CheckpointDiffState, type CheckpointDiffTarget, - checkpointDiffStateAtom, - createCheckpointDiffManager, - EMPTY_CHECKPOINT_DIFF_ATOM, - EMPTY_CHECKPOINT_DIFF_STATE, - getCheckpointDiffTargetKey, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +} from "@t3tools/client-runtime/state/threads"; -import { readEnvironmentApi } from "../environmentApi"; -import { subscribeProviderInvalidations } from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const checkpointDiffManager = createCheckpointDiffManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, -}); - -export function invalidateCheckpointDiffs(): void { - checkpointDiffManager.invalidate(); -} - -subscribeProviderInvalidations(invalidateCheckpointDiffs); +import { useCheckpointDiff as useCheckpointDiffQuery } from "../state/queries"; export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, ): CheckpointDiffState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - threadId: target.threadId, - fromTurnCount: target.fromTurnCount, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - cacheScope: target.cacheScope ?? null, - }), - [ - target.cacheScope, - target.environmentId, - target.fromTurnCount, - target.ignoreWhitespace, - target.threadId, - target.toTurnCount, - ], - ); - const targetKey = getCheckpointDiffTargetKey(stableTarget); - - useEffect(() => { - if (targetKey === null || options?.enabled === false) { - return; - } - void checkpointDiffManager.load(stableTarget); - }, [options?.enabled, stableTarget, targetKey]); - - const state = useAtomValue( - targetKey !== null ? checkpointDiffStateAtom(targetKey) : EMPTY_CHECKPOINT_DIFF_ATOM, - ); - return targetKey === null || options?.enabled === false ? EMPTY_CHECKPOINT_DIFF_STATE : state; + const state = useCheckpointDiffQuery(target, options); + return { + data: state.data, + error: state.error, + isPending: state.isPending, + }; } diff --git a/apps/web/src/lib/composerPathSearchState.ts b/apps/web/src/lib/composerPathSearchState.ts index e25f60ad13d..a2ad55c6775 100644 --- a/apps/web/src/lib/composerPathSearchState.ts +++ b/apps/web/src/lib/composerPathSearchState.ts @@ -1,57 +1,18 @@ -import { useAtomValue } from "@effect/atom-react"; import { type ComposerPathSearchState, type ComposerPathSearchTarget, - EMPTY_COMPOSER_PATH_SEARCH_ATOM, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - composerPathSearchStateAtom, - createComposerPathSearchManager, - getComposerPathSearchTargetKey, - normalizeComposerPathSearchQuery, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +} from "@t3tools/client-runtime/state/threads"; -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, - subscribeProviderInvalidations, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const COMPOSER_PATH_SEARCH_LIMIT = 80; -const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; -const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; - -const composerPathSearchManager = createComposerPathSearchManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.projects ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - limit: COMPOSER_PATH_SEARCH_LIMIT, - debounceMs: COMPOSER_PATH_SEARCH_DEBOUNCE_MS, - staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, -}); - -export function invalidateComposerPathSearches(): void { - composerPathSearchManager.invalidate(); -} - -subscribeProviderInvalidations(invalidateComposerPathSearches); +import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getComposerPathSearchTargetKey(stableTarget); - - useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue( - targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, - ); - return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; + const state = useComposerPathSearchQuery(target); + return { + entries: state.entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + })), + error: state.error, + isPending: state.isPending, + }; } diff --git a/apps/web/src/lib/desktopUpdateReactQuery.test.ts b/apps/web/src/lib/desktopUpdateReactQuery.test.ts deleted file mode 100644 index 5f53f77a3ae..00000000000 --- a/apps/web/src/lib/desktopUpdateReactQuery.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import { describe, expect, it } from "vite-plus/test"; -import type { DesktopUpdateState } from "@t3tools/contracts"; -import { - desktopUpdateQueryKeys, - desktopUpdateStateQueryOptions, - setDesktopUpdateStateQueryData, -} from "./desktopUpdateReactQuery"; - -const baseState: DesktopUpdateState = { - enabled: true, - status: "idle", - channel: "latest", - currentVersion: "1.0.0", - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, -}; - -describe("desktopUpdateStateQueryOptions", () => { - it("always refetches on mount so Settings does not reuse stale desktop update state", () => { - const options = desktopUpdateStateQueryOptions(); - - expect(options.staleTime).toBe(Infinity); - expect(options.refetchOnMount).toBe("always"); - }); -}); - -describe("setDesktopUpdateStateQueryData", () => { - it("writes desktop update state into the shared cache key", () => { - const queryClient = new QueryClient(); - const nextState: DesktopUpdateState = { - ...baseState, - status: "downloaded", - availableVersion: "1.1.0", - downloadedVersion: "1.1.0", - }; - - setDesktopUpdateStateQueryData(queryClient, nextState); - - expect(queryClient.getQueryData(desktopUpdateQueryKeys.state())).toEqual(nextState); - }); -}); diff --git a/apps/web/src/lib/desktopUpdateReactQuery.ts b/apps/web/src/lib/desktopUpdateReactQuery.ts deleted file mode 100644 index 9315772786a..00000000000 --- a/apps/web/src/lib/desktopUpdateReactQuery.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { queryOptions, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; -import type { DesktopUpdateState } from "@t3tools/contracts"; - -export const desktopUpdateQueryKeys = { - all: ["desktop", "update"] as const, - state: () => ["desktop", "update", "state"] as const, -}; - -export const setDesktopUpdateStateQueryData = ( - queryClient: QueryClient, - state: DesktopUpdateState | null, -) => queryClient.setQueryData(desktopUpdateQueryKeys.state(), state); - -export function desktopUpdateStateQueryOptions() { - return queryOptions({ - queryKey: desktopUpdateQueryKeys.state(), - queryFn: async () => { - const bridge = window.desktopBridge; - if (!bridge || typeof bridge.getUpdateState !== "function") return null; - return bridge.getUpdateState(); - }, - staleTime: Infinity, - refetchOnMount: "always", - }); -} - -export function useDesktopUpdateState() { - const queryClient = useQueryClient(); - const query = useQuery(desktopUpdateStateQueryOptions()); - - useEffect(() => { - const bridge = window.desktopBridge; - if (!bridge || typeof bridge.onUpdateState !== "function") return; - - return bridge.onUpdateState((nextState) => { - setDesktopUpdateStateQueryData(queryClient, nextState); - }); - }, [queryClient]); - - return query; -} diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index c24f58b99dd..e75a893d6b3 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPatchCacheKey } from "./diffRendering"; +import { buildPatchCacheKey, getRenderablePatch } from "./diffRendering"; describe("buildPatchCacheKey", () => { it("returns a stable cache key for identical content", () => { @@ -29,3 +29,56 @@ describe("buildPatchCacheKey", () => { ); }); }); + +describe("getRenderablePatch", () => { + it("compacts partial hunk render offsets for virtualized review diffs", () => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "index 1111111..2222222 100644", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -48,4 +48,4 @@", + " context", + "-before", + "+after", + " context", + " context", + "@@ -80,3 +80,4 @@", + " context", + "+added", + " context", + " context", + ].join("\n"); + + const parsed = getRenderablePatch(patch, "review", { + compactPartialHunkOffsets: true, + }); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + + const file = parsed.files[0]; + expect(file?.hunks[0]?.collapsedBefore).toBe(47); + expect(file?.hunks[0]?.unifiedLineStart).toBe(0); + expect(file?.hunks[1]?.collapsedBefore).toBeGreaterThan(0); + expect(file?.hunks[1]?.unifiedLineStart).toBe(file?.hunks[0]?.unifiedLineCount); + expect(file?.unifiedLineCount).toBe( + file?.hunks.reduce((total, hunk) => total + hunk.unifiedLineCount, 0), + ); + }); + + it("retains source-file offsets for checkpoint diffs", () => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -48,1 +48,1 @@", + "-before", + "+after", + ].join("\n"); + + const parsed = getRenderablePatch(patch, "checkpoint"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + expect(parsed.files[0]?.hunks[0]?.unifiedLineStart).toBe(47); + }); +}); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index cb57ec7e065..cb8318b3d2d 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -52,9 +52,45 @@ export type RenderablePatch = reason: string; }; +interface RenderablePatchOptions { + /** + * Pierre's partial-patch parser keeps hunk render starts in source-file + * coordinates. Its virtualizer iterates partial patches as compact rows, so + * review diffs need compact render starts while retaining collapsedBefore + * for the "N unmodified lines" separator. + */ + compactPartialHunkOffsets?: boolean; +} + +export function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { + if (!file.isPartial) return file; + + let splitLineStart = 0; + let unifiedLineStart = 0; + const hunks = file.hunks.map((hunk) => { + const compactHunk = { + ...hunk, + splitLineStart, + unifiedLineStart, + }; + splitLineStart += hunk.splitLineCount; + unifiedLineStart += hunk.unifiedLineCount; + return compactHunk; + }); + + return { + ...file, + hunks, + splitLineCount: splitLineStart, + unifiedLineCount: unifiedLineStart, + ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:compact-partial` } : {}), + }; +} + export function getRenderablePatch( patch: string | undefined, cacheScope = "diff-panel", + options: RenderablePatchOptions = {}, ): RenderablePatch | null { if (!patch) return null; const normalizedPatch = patch.trim(); @@ -65,7 +101,11 @@ export function getRenderablePatch( normalizedPatch, buildPatchCacheKey(normalizedPatch, cacheScope), ); - const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); + const files = parsedPatches.flatMap((parsedPatch) => + options.compactPartialHunkOffsets + ? parsedPatch.files.map(compactPartialHunkOffsets) + : parsedPatch.files, + ); if (files.length > 0) { return { kind: "files", files }; } diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts new file mode 100644 index 00000000000..756e1ed6ad9 --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { openPullRequestLink, PullRequestLinkOpenError } from "./openPullRequestLink"; + +describe("openPullRequestLink", () => { + it("opens the requested pull request URL", async () => { + const openExternal = vi.fn(async () => undefined); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123"; + + await openPullRequestLink({ openExternal }, targetUrl); + + expect(openExternal).toHaveBeenCalledExactlyOnceWith(targetUrl); + }); + + it("reports bridge failures with a safe target origin", async () => { + const cause = new Error("desktop shell unavailable"); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123?token=secret"; + const openExternal = vi.fn(async () => Promise.reject(cause)); + + const result = openPullRequestLink({ openExternal }, targetUrl); + + await expect(result).rejects.toEqual( + new PullRequestLinkOpenError({ + targetOrigin: "https://github.com", + cause, + }), + ); + await expect(result).rejects.not.toHaveProperty("message", expect.stringContaining("secret")); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts new file mode 100644 index 00000000000..acd3c5a062b --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -0,0 +1,75 @@ +import type { LocalApi } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { type MouseEvent, useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { readLocalApi } from "../localApi"; + +export class PullRequestLinkOpenError extends Schema.TaggedErrorClass()( + "PullRequestLinkOpenError", + { + targetOrigin: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(targetUrl: string, cause: unknown): PullRequestLinkOpenError { + let targetOrigin: string | null = null; + try { + targetOrigin = new URL(targetUrl).origin; + } catch { + // Keep malformed URLs out of diagnostics while preserving the open failure below. + } + return new PullRequestLinkOpenError({ targetOrigin, cause }); + } + + override get message(): string { + return this.targetOrigin === null + ? "Unable to open pull request link." + : `Unable to open pull request link at ${this.targetOrigin}.`; + } +} + +export async function openPullRequestLink( + shell: Pick, + targetUrl: string, +): Promise { + try { + await shell.openExternal(targetUrl); + } catch (cause) { + throw PullRequestLinkOpenError.fromCause(targetUrl, cause); + } +} + +/** + * Returns a click handler that opens a pull request URL in the system browser. + * + * Stops event propagation/default so activating the link does not also trigger + * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a + * toast when the local API is unavailable or the open fails. + */ +export function useOpenPrLink() { + return useCallback((event: MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } + + void openPullRequestLink(api.shell, prUrl).catch((error) => { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open pull request link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, []); +} diff --git a/apps/web/src/lib/processDiagnosticsState.ts b/apps/web/src/lib/processDiagnosticsState.ts deleted file mode 100644 index 7e1b3d698a6..00000000000 --- a/apps/web/src/lib/processDiagnosticsState.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { - ServerProcessDiagnosticsResult, - ServerProcessResourceHistoryResult, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; - -import { ensureLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const PROCESS_DIAGNOSTICS_STALE_TIME_MS = 2_000; -const PROCESS_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; -const PROCESS_RESOURCE_HISTORY_STALE_TIME_MS = 5_000; -const PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR = ":"; - -const processDiagnosticsAtom = Atom.make( - Effect.promise(() => ensureLocalApi().server.getProcessDiagnostics()), -).pipe( - Atom.swr({ - staleTime: PROCESS_DIAGNOSTICS_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel("process-diagnostics"), -); - -function formatProcessResourceHistoryKey(input: { - readonly windowMs: number; - readonly bucketMs: number; -}): string { - return `${input.windowMs}${PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`; -} - -function parseProcessResourceHistoryKey(key: string): { - readonly windowMs: number; - readonly bucketMs: number; -} { - const [windowMs = "0", bucketMs = "0"] = key.split(PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR); - return { - windowMs: Number(windowMs), - bucketMs: Number(bucketMs), - }; -} - -const processResourceHistoryAtom = Atom.family((key: string) => { - const input = parseProcessResourceHistoryKey(key); - return Atom.make( - Effect.promise(() => ensureLocalApi().server.getProcessResourceHistory(input)), - ).pipe( - Atom.swr({ - staleTime: PROCESS_RESOURCE_HISTORY_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel(`process-resource-history:${key}`), - ); -}); - -export interface ProcessDiagnosticsState { - readonly data: ServerProcessDiagnosticsResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -export interface ProcessResourceHistoryState { - readonly data: ServerProcessResourceHistoryResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -function formatProcessDiagnosticsError(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load process diagnostics."; -} - -function readProcessDiagnosticsError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatProcessDiagnosticsError(squashed); -} - -function readProcessResourceHistoryError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatProcessDiagnosticsError(squashed); -} - -export function refreshProcessDiagnostics(): void { - appAtomRegistry.refresh(processDiagnosticsAtom); -} - -export function useProcessDiagnostics(): ProcessDiagnosticsState { - const result = useAtomValue(processDiagnosticsAtom); - const data = Option.getOrNull(AsyncResult.value(result)); - const refresh = useCallback(() => { - refreshProcessDiagnostics(); - }, []); - - return { - data, - error: readProcessDiagnosticsError(result), - isPending: result.waiting, - refresh, - }; -} - -export function useProcessResourceHistory(input: { - readonly windowMs: number; - readonly bucketMs: number; -}): ProcessResourceHistoryState { - const atom = processResourceHistoryAtom(formatProcessResourceHistoryKey(input)); - const result = useAtomValue(atom); - const data = Option.getOrNull(AsyncResult.value(result)); - - const refresh = useCallback(() => { - appAtomRegistry.refresh(atom); - }, [atom]); - - return { - data, - error: readProcessResourceHistoryError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts index da0233ccfb3..262095c663c 100644 --- a/apps/web/src/lib/projectPaths.ts +++ b/apps/web/src/lib/projectPaths.ts @@ -14,4 +14,4 @@ export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch, resolveProjectPathForDispatch, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/projects"; diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 1d7903ced06..3836d2a3916 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -1,49 +1,44 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import type * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { FetchHttpClient } from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; -import { remoteHttpClientLayer } from "@t3tools/client-runtime"; -import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; -import { - PrimaryEnvironmentHttpClient, - primaryEnvironmentHttpClientLive, -} from "../environments/primary/httpClient"; -import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { browserCryptoLayer } from "../cloud/dpop"; -import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; } -const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); -const webRelayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { +const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); +const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { serviceName: "t3-web-relay-client", serviceVersion: import.meta.env.APP_VERSION, runtime: "browser", client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", -}).pipe(Layer.provide(webHttpClientLayer)); +}).pipe(Layer.provide(httpClientLayer)); -export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer); +type RuntimeLayerSource = + | typeof httpClientLayer + | typeof browserCryptoLayer + | typeof Socket.layerWebSocketConstructorGlobal + | typeof relayTracingLayer + | ReturnType; + +export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( - primaryEnvironmentHttpClientLive.pipe( - Layer.provide( - Layer.mergeAll( - remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), - Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), - httpHeaderRedactionLayer, - ), - ), - ), + PrimaryEnvironmentHttpClient.layer.pipe(Layer.provide(primaryEnvironmentHttpLayer)), ); export type PrimaryHttpEffectRunner = ( - effect: Effect.Effect, + effect: Effect.Effect, ) => Promise; const livePrimaryHttpRunner: PrimaryHttpEffectRunner = (effect) => @@ -51,20 +46,30 @@ const livePrimaryHttpRunner: PrimaryHttpEffectRunner = (effect) => let primaryHttpRunner = livePrimaryHttpRunner; -export const runPrimaryHttp = (effect: Effect.Effect) => - primaryHttpRunner(effect); +export const runPrimaryHttp = ( + effect: Effect.Effect, +) => primaryHttpRunner(effect); export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void { primaryHttpRunner = runner ?? livePrimaryHttpRunner; } -export const webRuntime = ManagedRuntime.make( - Layer.mergeAll( - webHttpClientLayer, - browserCryptoLayer, - webManagedRelayClientLayer(configuredRelayUrl()).pipe( - Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)), - Layer.provideMerge(webRelayTracingLayer), - ), +const runtimeLayer = Layer.mergeAll( + httpClientLayer, + browserCryptoLayer, + Socket.layerWebSocketConstructorGlobal, + relayTracingLayer, + managedRelayClientLayer(configuredRelayUrl()).pipe( + Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)), ), ); + +export const runtime: ManagedRuntime.ManagedRuntime< + Layer.Success, + Layer.Error +> = ManagedRuntime.make(runtimeLayer); + +export const runtimeContextLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.effectContext(runtime.contextEffect); diff --git a/apps/web/src/lib/sourceControlActions.ts b/apps/web/src/lib/sourceControlActions.ts index 917b8c3a9b2..2d857c8e4b7 100644 --- a/apps/web/src/lib/sourceControlActions.ts +++ b/apps/web/src/lib/sourceControlActions.ts @@ -1,477 +1,10 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsActionOperation, - type VcsActionState, - EMPTY_VCS_ACTION_ATOM, - EMPTY_VCS_ACTION_STATE, - createVcsActionManager, - getVcsActionTargetKey, - vcsActionStateAtom, -} from "@t3tools/client-runtime"; -import { - type EnvironmentId, - type GitActionProgressEvent, - type GitRunStackedActionResult, - type GitStackedAction, - type GitResolvePullRequestResult, - type SourceControlCloneProtocol, - type SourceControlPublishRepositoryResult, - type SourceControlRepositoryVisibility, - type ThreadId, - type VcsPullResult, -} from "@t3tools/contracts"; -import { - useCallback, - useEffect, - useMemo, - useState, - useSyncExternalStore, - useTransition, -} from "react"; - -import { ensureEnvironmentApi } from "../environmentApi"; -import { readEnvironmentConnection } from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; -import { getVcsStatusSnapshot, refreshVcsStatus } from "./vcsStatusState"; -import { vcsRefManager } from "./vcsRefState"; - -type SourceControlActionKind = - | "init" - | "pull" - | "publishRepository" - | "runStackedAction" - | "preparePullRequestThread"; - -interface SourceControlActionScope { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -interface SourceControlActionState, TResult> { - readonly isPending: boolean; - readonly error: unknown; - readonly run: (...args: TArgs) => Promise; - readonly resetError: () => void; -} - -export const vcsActionManager = createVcsActionManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = readEnvironmentConnection(environmentId)?.client; - return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; - }, - onInvalidate: (target) => invalidateSourceControlState(target), -}); - -const actionListeners = new Set<() => void>(); -const activeActionCounts = new Map(); - -function notifyActionListeners(): void { - for (const listener of actionListeners) { - listener(); - } -} - -function subscribeActionState(listener: () => void): () => void { - actionListeners.add(listener); - return () => { - actionListeners.delete(listener); - }; -} - -function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string { - return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`; -} - -function beginAction(key: string): () => void { - activeActionCounts.set(key, (activeActionCounts.get(key) ?? 0) + 1); - notifyActionListeners(); - let ended = false; - return () => { - if (ended) { - return; - } - ended = true; - const next = (activeActionCounts.get(key) ?? 1) - 1; - if (next <= 0) { - activeActionCounts.delete(key); - } else { - activeActionCounts.set(key, next); - } - notifyActionListeners(); - }; -} - -function isAnyActionRunning( - kinds: ReadonlyArray, - scope: SourceControlActionScope, -): boolean { - return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0); -} - -function getVcsActionOperationForKind(kind: SourceControlActionKind): VcsActionOperation | null { - switch (kind) { - case "init": - return "init"; - case "pull": - return "pull"; - case "runStackedAction": - return "run_change_request"; - case "publishRepository": - case "preparePullRequestThread": - return null; - } -} - -function useVcsActionStateForScope(scope: SourceControlActionScope): VcsActionState { - const targetKey = getVcsActionTargetKey(scope); - const state = useAtomValue( - targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, - ); - return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; -} - -export function invalidateSourceControlState(scope?: { - readonly environmentId?: EnvironmentId | null; - readonly cwd?: string | null; -}): Promise { - const environmentId = scope?.environmentId ?? null; - const cwd = scope?.cwd ?? null; - if (cwd !== null) { - vcsRefManager.invalidateScope({ environmentId, cwd }); - if (environmentId !== null) { - return refreshVcsStatus({ environmentId, cwd }).then( - () => undefined, - () => undefined, - ); - } - return Promise.resolve(); - } - - vcsRefManager.invalidate(); - return Promise.resolve(); -} - -function useSourceControlAction, TResult>(input: { - readonly kind: SourceControlActionKind; - readonly scope: SourceControlActionScope; - readonly action: (...args: TArgs) => Promise; - readonly invalidateOnSuccess?: boolean; -}): SourceControlActionState { - const { action, invalidateOnSuccess = true, kind, scope } = input; - const [error, setError] = useState(null); - const [activeCount, setActiveCount] = useState(0); - const [isTransitionPending, startTransition] = useTransition(); - const key = actionKey(kind, scope); - - const resetError = useCallback(() => { - startTransition(() => setError(null)); - }, [startTransition]); - - const run = useCallback( - async (...args: TArgs): Promise => { - const endAction = beginAction(key); - startTransition(() => { - setError(null); - setActiveCount((count) => count + 1); - }); - try { - const result = await action(...args); - if (invalidateOnSuccess) { - await invalidateSourceControlState(scope); - } - return result; - } catch (nextError) { - startTransition(() => setError(nextError)); - throw nextError; - } finally { - endAction(); - startTransition(() => setActiveCount((count) => Math.max(0, count - 1))); - } - }, - [action, invalidateOnSuccess, key, scope, startTransition], - ); - - return { - error, - isPending: activeCount > 0 || isTransitionPending, - resetError, - run, - }; -} - -export function useSourceControlActionRunning( - scope: SourceControlActionScope, - kinds: ReadonlyArray, -): boolean { - const stableKinds = useMemo(() => kinds.toSorted(), [kinds]); - const appActionRunning = useSyncExternalStore( - subscribeActionState, - () => isAnyActionRunning(stableKinds, scope), - () => false, - ); - const vcsActionState = useVcsActionStateForScope(scope); - const vcsActionRunning = - vcsActionState.isRunning && - stableKinds.some((kind) => getVcsActionOperationForKind(kind) === vcsActionState.operation); - - return appActionRunning || vcsActionRunning; -} - -function useVcsManagerAction, TResult>(input: { - readonly operation: VcsActionOperation; - readonly scope: SourceControlActionScope; - readonly unavailableMessage: string; - readonly action: (...args: TArgs) => Promise; -}): SourceControlActionState { - const { action, operation, scope, unavailableMessage } = input; - const vcsActionState = useVcsActionStateForScope(scope); - const [error, setError] = useState(null); - const [isTransitionPending, startTransition] = useTransition(); - - const resetError = useCallback(() => { - vcsActionManager.reset(scope); - startTransition(() => setError(null)); - }, [scope, startTransition]); - - const run = useCallback( - async (...args: TArgs): Promise => { - startTransition(() => setError(null)); - try { - const result = await action(...args); - if (result === null) { - throw new Error(unavailableMessage); - } - return result; - } catch (nextError) { - startTransition(() => setError(nextError)); - throw nextError; - } - }, - [action, startTransition, unavailableMessage], - ); - - return { - error: error ?? vcsActionState.error, - isPending: - isTransitionPending || (vcsActionState.isRunning && vcsActionState.operation === operation), - resetError, - run, - }; -} - -export function useVcsInitAction(scope: SourceControlActionScope) { - const action = useCallback(async () => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git init is unavailable."); - return vcsActionManager.init(scope); - }, [scope]); - - return useVcsManagerAction({ - operation: "init", - scope, - unavailableMessage: "Git init is unavailable.", - action, - }); -} - -export function useGitStackedAction(scope: SourceControlActionScope) { - const action = useCallback( - async ({ - actionId, - action, - commitMessage, - featureBranch, - filePaths, - onProgress, - }: { - actionId: string; - action: GitStackedAction; - commitMessage?: string; - featureBranch?: boolean; - filePaths?: string[]; - onProgress?: (event: GitActionProgressEvent) => void; - }): Promise => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git action is unavailable."); - return vcsActionManager.runChangeRequest( - scope, - { - actionId, - action, - ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch: true } : {}), - ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), - }, - { - gitStatus: getVcsStatusSnapshot(scope).data, - ...(onProgress ? { onProgress } : {}), - }, - ); - }, - [scope], - ); - - return useVcsManagerAction({ - operation: "run_change_request", - scope, - unavailableMessage: "Git action is unavailable.", - action, - }); -} - -export function useVcsPullAction(scope: SourceControlActionScope) { - const action = useCallback(async (): Promise => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git pull is unavailable."); - return vcsActionManager.pull(scope); - }, [scope]); - - return useVcsManagerAction({ - operation: "pull", - scope, - unavailableMessage: "Git pull is unavailable.", - action, - }); -} - -export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { - const action = useCallback( - async (args: { - provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; - repository: string; - visibility: SourceControlRepositoryVisibility; - remoteName: string; - protocol: SourceControlCloneProtocol; - }): Promise => { - if (!scope.cwd || !scope.environmentId) { - throw new Error("Repository publishing is unavailable."); - } - return ensureEnvironmentApi(scope.environmentId).sourceControl.publishRepository({ - cwd: scope.cwd, - ...args, - }); - }, - [scope], - ); - - return useSourceControlAction({ - kind: "publishRepository", - scope, - action, - }); -} - -export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { - const action = useCallback( - async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { - if (!scope.cwd || !scope.environmentId) { - throw new Error("Pull request thread preparation is unavailable."); - } - return ensureEnvironmentApi(scope.environmentId).git.preparePullRequestThread({ - cwd: scope.cwd, - reference: args.reference, - mode: args.mode, - ...(args.threadId ? { threadId: args.threadId } : {}), - }); - }, - [scope], - ); - - return useSourceControlAction({ - kind: "preparePullRequestThread", - scope, - action, - }); -} - -interface PullRequestResolutionTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly reference: string | null; -} - -interface PullRequestResolutionState { - readonly data: GitResolvePullRequestResult | null; - readonly error: unknown; - readonly isPending: boolean; - readonly isFetching: boolean; -} - -const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = { - data: null, - error: null, - isPending: false, - isFetching: false, -}; - -const pullRequestResolutionCache = new Map(); - -function pullRequestResolutionKey(target: PullRequestResolutionTarget): string | null { - if (!target.environmentId || !target.cwd || !target.reference) { - return null; - } - return `${target.environmentId}:${target.cwd}:${target.reference}`; -} - -export function readCachedPullRequestResolution( - target: PullRequestResolutionTarget, -): GitResolvePullRequestResult | null { - const key = pullRequestResolutionKey(target); - return key ? (pullRequestResolutionCache.get(key) ?? null) : null; -} - -export function usePullRequestResolution( - target: PullRequestResolutionTarget, -): PullRequestResolutionState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - reference: target.reference, - }), - [target.cwd, target.environmentId, target.reference], - ); - const key = pullRequestResolutionKey(stableTarget); - const [state, setState] = useState(() => { - const cached = readCachedPullRequestResolution(stableTarget); - return cached - ? { data: cached, error: null, isPending: false, isFetching: false } - : EMPTY_PULL_REQUEST_RESOLUTION; - }); - - useEffect(() => { - if (!key || !stableTarget.environmentId || !stableTarget.cwd || !stableTarget.reference) { - setState(EMPTY_PULL_REQUEST_RESOLUTION); - return; - } - - const cached = pullRequestResolutionCache.get(key) ?? null; - setState({ - data: cached, - error: null, - isPending: cached === null, - isFetching: true, - }); - - let cancelled = false; - ensureEnvironmentApi(stableTarget.environmentId) - .git.resolvePullRequest({ cwd: stableTarget.cwd, reference: stableTarget.reference }) - .then((result) => { - if (cancelled) { - return; - } - pullRequestResolutionCache.set(key, result); - setState({ data: result, error: null, isPending: false, isFetching: false }); - }) - .catch((error: unknown) => { - if (cancelled) { - return; - } - setState({ data: cached, error, isPending: false, isFetching: false }); - }); - - return () => { - cancelled = true; - }; - }, [key, stableTarget]); - - return state; -} +export { + readCachedPullRequestResolution, + useGitStackedAction, + usePreparePullRequestThreadAction, + usePullRequestResolutionState as usePullRequestResolution, + useSourceControlActionRunning, + useSourceControlPublishRepositoryAction, + useVcsInitAction, + useVcsPullAction, +} from "../state/sourceControlActions"; diff --git a/apps/web/src/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts deleted file mode 100644 index 133f09d252a..00000000000 --- a/apps/web/src/lib/sourceControlDiscoveryState.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type SourceControlDiscoveryTarget, - type SourceControlDiscoveryState, - createSourceControlDiscoveryManager, - getSourceControlDiscoveryTargetKey, - sourceControlDiscoveryStateAtom, -} from "@t3tools/client-runtime"; -import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts"; -import { useEffect } from "react"; - -import { readPrimaryEnvironmentDescriptor } from "../environments/primary"; -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { readLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; -const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; -const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; - -interface SourceControlDiscoveryTargetInput { - readonly environmentId?: EnvironmentId | null; -} - -function sourceControlDiscoveryTarget( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryTarget { - const environmentId = input?.environmentId ?? null; - if (!environmentId) { - return SOURCE_CONTROL_DISCOVERY_TARGET; - } - return readPrimaryEnvironmentDescriptor()?.environmentId === environmentId - ? SOURCE_CONTROL_DISCOVERY_TARGET - : { key: environmentId }; -} - -const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ - getRegistry: () => appAtomRegistry, - getClient: (key) => { - if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { - const primaryEnvironmentId = readPrimaryEnvironmentDescriptor()?.environmentId ?? null; - const primaryConnection = primaryEnvironmentId - ? readEnvironmentConnection(primaryEnvironmentId) - : null; - if (primaryConnection) { - return primaryConnection.client.server; - } - try { - return readLocalApi()?.server ?? null; - } catch { - return null; - } - } - const environmentId = EnvironmentId.make(key); - const connection = readEnvironmentConnection(environmentId); - if (connection) { - return connection.client.server; - } - return null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - staleTimeMs: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, - idleTtlMs: SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS, -}); - -export function refreshSourceControlDiscovery( - input?: SourceControlDiscoveryTargetInput, -): Promise { - return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input)); -} - -export function getSourceControlDiscoverySnapshot( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryState { - return sourceControlDiscoveryManager.getSnapshot(sourceControlDiscoveryTarget(input)); -} - -export function resetSourceControlDiscoveryStateForTests(): void { - sourceControlDiscoveryManager.reset(); -} - -export function useSourceControlDiscovery( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryState { - const targetKey = - getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ?? - SOURCE_CONTROL_DISCOVERY_TARGET.key; - - useEffect(() => sourceControlDiscoveryManager.watch({ key: targetKey }), [targetKey]); - - return useAtomValue(sourceControlDiscoveryStateAtom(targetKey)); -} diff --git a/apps/web/src/lib/terminalUiStateCleanup.test.ts b/apps/web/src/lib/terminalUiStateCleanup.test.ts index f96436fc976..a7fa1c1d317 100644 --- a/apps/web/src/lib/terminalUiStateCleanup.test.ts +++ b/apps/web/src/lib/terminalUiStateCleanup.test.ts @@ -1,4 +1,4 @@ -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index 3a8596b4957..e3f1a79cacf 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -16,7 +16,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: LOCAL_ENVIRONMENT_ID, - codexThreadId: null, projectId: PROJECT_ID, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, @@ -25,14 +24,14 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], - error: null, createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, - turnDiffSummaries: [], + checkpoints: [], activities: [], goal: null, ...overrides, @@ -51,9 +50,10 @@ describe("sortThreads", () => { id: "message-1" as never, role: "user", text: "older", + turnId: null, createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", }, ], }), @@ -66,9 +66,10 @@ describe("sortThreads", () => { id: "message-2" as never, role: "user", text: "newer", + turnId: null, createdAt: "2026-03-09T10:06:00.000Z", + updatedAt: "2026-03-09T10:06:00.000Z", streaming: false, - completedAt: "2026-03-09T10:06:00.000Z", }, ], }), @@ -93,9 +94,10 @@ describe("sortThreads", () => { id: "message-1" as never, role: "assistant", text: "assistant only", + turnId: null, createdAt: "2026-03-09T10:02:00.000Z", + updatedAt: "2026-03-09T10:02:00.000Z", streaming: false, - completedAt: "2026-03-09T10:02:00.000Z", }, ], }), @@ -145,14 +147,14 @@ describe("sortThreads", () => { [ makeThread({ id: ThreadId.make("thread-1"), - createdAt: "" as never, - updatedAt: undefined, + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, messages: [], }), makeThread({ id: ThreadId.make("thread-2"), - createdAt: "" as never, - updatedAt: undefined, + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, messages: [], }), ], diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index de8b22e93c5..ac3dea3aca5 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,86 +1,7 @@ -import type { ProjectId } from "@t3tools/contracts"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import type { Thread } from "../types"; - -export type ThreadSortInput = Pick & { - latestUserMessageAt?: string | null; - messages?: Pick[]; -}; - -export function toSortableTimestamp(iso: string | undefined): number | null { - if (!iso) return null; - const ms = Date.parse(iso); - return Number.isFinite(ms) ? ms : null; -} - -function getFirstSortableTimestamp(...values: Array): number | null { - for (const value of values) { - const timestamp = toSortableTimestamp(value ?? undefined); - if (timestamp !== null) { - return timestamp; - } - } - - return null; -} - -function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { - if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; - } - - let latestUserMessageTimestamp: number | null = null; - - for (const message of thread.messages ?? []) { - if (message.role !== "user") continue; - const messageTimestamp = toSortableTimestamp(message.createdAt); - if (messageTimestamp === null) continue; - latestUserMessageTimestamp = - latestUserMessageTimestamp === null - ? messageTimestamp - : Math.max(latestUserMessageTimestamp, messageTimestamp); - } - - if (latestUserMessageTimestamp !== null) { - return latestUserMessageTimestamp; - } - - return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; -} - -export function getThreadSortTimestamp( - thread: ThreadSortInput, - sortOrder: SidebarThreadSortOrder | Exclude, -): number { - if (sortOrder === "created_at") { - return ( - getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY - ); - } - return getLatestUserMessageTimestamp(thread); -} - -export function sortThreads & ThreadSortInput>( - threads: readonly T[], - sortOrder: SidebarThreadSortOrder, -): T[] { - return threads.toSorted((left, right) => { - const rightTimestamp = getThreadSortTimestamp(right, sortOrder); - const leftTimestamp = getThreadSortTimestamp(left, sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - if (byTimestamp !== 0) return byTimestamp; - return right.id.localeCompare(left.id); - }); -} - -export function getLatestThreadForProject< - T extends Pick & ThreadSortInput, ->(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { - return ( - sortThreads( - threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), - sortOrder, - )[0] ?? null - ); -} +export { + getLatestThreadForProject, + getThreadSortTimestamp, + sortThreads, + toSortableTimestamp, + type ThreadSortInput, +} from "@t3tools/client-runtime/state/thread-sort"; diff --git a/apps/web/src/lib/traceDiagnosticsState.ts b/apps/web/src/lib/traceDiagnosticsState.ts deleted file mode 100644 index 73d9a6c3949..00000000000 --- a/apps/web/src/lib/traceDiagnosticsState.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { ServerTraceDiagnosticsResult } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; - -import { ensureLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const TRACE_DIAGNOSTICS_STALE_TIME_MS = 5_000; -const TRACE_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; - -const traceDiagnosticsAtom = Atom.make( - Effect.promise(() => ensureLocalApi().server.getTraceDiagnostics()), -).pipe( - Atom.swr({ - staleTime: TRACE_DIAGNOSTICS_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(TRACE_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel("trace-diagnostics"), -); - -export interface TraceDiagnosticsState { - readonly data: ServerTraceDiagnosticsResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -function formatTraceDiagnosticsError(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load trace diagnostics."; -} - -function readTraceDiagnosticsError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatTraceDiagnosticsError(squashed); -} - -export function refreshTraceDiagnostics(): void { - appAtomRegistry.refresh(traceDiagnosticsAtom); -} - -export function useTraceDiagnostics(): TraceDiagnosticsState { - const result = useAtomValue(traceDiagnosticsAtom); - const data = Option.getOrNull(AsyncResult.value(result)); - const refresh = useCallback(() => { - refreshTraceDiagnostics(); - }, []); - - return { - data, - error: readTraceDiagnosticsError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index 555fc71f01f..47b428bc3a2 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -5,9 +5,9 @@ import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; describe("summarizeTurnDiffStats", () => { it("sums only files with numeric additions/deletions", () => { const stat = summarizeTurnDiffStats([ - { path: "README.md", additions: 3, deletions: 1 }, - { path: "docs/notes.md" }, - { path: "src/index.ts", additions: 5, deletions: 2 }, + { path: "README.md", kind: "modified", additions: 3, deletions: 1 }, + { path: "docs/notes.md", kind: "modified", additions: 0, deletions: 0 }, + { path: "src/index.ts", kind: "modified", additions: 5, deletions: 2 }, ]); expect(stat).toEqual({ additions: 8, deletions: 3 }); @@ -17,9 +17,9 @@ describe("summarizeTurnDiffStats", () => { describe("buildTurnDiffTree", () => { it("builds nested directory nodes with aggregated stats", () => { const tree = buildTurnDiffTree([ - { path: "src/index.ts", additions: 2, deletions: 1 }, - { path: "src/components/Button.tsx", additions: 4, deletions: 2 }, - { path: "README.md", additions: 1, deletions: 0 }, + { path: "src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "src/components/Button.tsx", kind: "modified", additions: 4, deletions: 2 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, ]); expect(tree).toEqual([ @@ -60,10 +60,10 @@ describe("buildTurnDiffTree", () => { ]); }); - it("keeps files without stat values and excludes them from directory totals", () => { + it("keeps zero-valued file stats and includes only their numeric contribution", () => { const tree = buildTurnDiffTree([ - { path: "docs/notes.md" }, - { path: "docs/todo.md", additions: 1, deletions: 1 }, + { path: "docs/notes.md", kind: "modified", additions: 0, deletions: 0 }, + { path: "docs/todo.md", kind: "modified", additions: 1, deletions: 1 }, ]); expect(tree).toEqual([ @@ -77,7 +77,7 @@ describe("buildTurnDiffTree", () => { kind: "file", name: "notes.md", path: "docs/notes.md", - stat: null, + stat: { additions: 0, deletions: 0 }, }, { kind: "file", @@ -92,7 +92,7 @@ describe("buildTurnDiffTree", () => { it("normalizes file paths with windows separators", () => { const tree = buildTurnDiffTree([ - { path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }, + { path: "apps\\web\\src\\index.ts", kind: "modified", additions: 2, deletions: 1 }, ]); expect(tree).toEqual([ @@ -115,8 +115,8 @@ describe("buildTurnDiffTree", () => { it("compacts only single-directory chains and stops at branch points", () => { const tree = buildTurnDiffTree([ - { path: "apps/server/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/server/main.ts", additions: 4, deletions: 0 }, + { path: "apps/server/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/server/main.ts", kind: "modified", additions: 4, deletions: 0 }, ]); expect(tree).toEqual([ @@ -153,8 +153,8 @@ describe("buildTurnDiffTree", () => { it("preserves leading/trailing whitespace in path segments", () => { const tree = buildTurnDiffTree([ - { path: "a/file.ts", additions: 1, deletions: 0 }, - { path: " a/file.ts", additions: 2, deletions: 0 }, + { path: "a/file.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: " a/file.ts", kind: "modified", additions: 2, deletions: 0 }, ]); expect(tree).toHaveLength(2); diff --git a/apps/web/src/lib/vcsRefState.ts b/apps/web/src/lib/vcsRefState.ts deleted file mode 100644 index 8addc03c5f7..00000000000 --- a/apps/web/src/lib/vcsRefState.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsRefState, - type VcsRefTarget, - EMPTY_VCS_REF_ATOM, - EMPTY_VCS_REF_STATE, - createVcsRefManager, - getVcsRefTargetKey, - vcsRefStateAtom, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; - -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const VCS_REF_LIST_LIMIT = 100; -const VCS_REF_STALE_TIME_MS = 5_000; - -export const vcsRefManager = createVcsRefManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.vcs ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - watchLimit: VCS_REF_LIST_LIMIT, - staleTimeMs: VCS_REF_STALE_TIME_MS, - onBackgroundError: (error) => { - console.warn("[vcs-refs] background refresh failed", error); - }, -}); - -export function useVcsRefs(target: VcsRefTarget): VcsRefState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: target.query ?? null, - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getVcsRefTargetKey(stableTarget); - - useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); - return targetKey === null ? EMPTY_VCS_REF_STATE : state; -} diff --git a/apps/web/src/lib/vcsStatusState.ts b/apps/web/src/lib/vcsStatusState.ts deleted file mode 100644 index 6d0bba3bdcc..00000000000 --- a/apps/web/src/lib/vcsStatusState.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsStatusClient, - type VcsStatusState, - type VcsStatusTarget, - EMPTY_VCS_STATUS_ATOM, - EMPTY_VCS_STATUS_STATE, - createVcsStatusManager, - getVcsStatusDataForTarget, - getVcsStatusTargetKey, - vcsStatusStateAtom, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useEffect } from "react"; - -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -export type { VcsStatusState, VcsStatusTarget }; -export { getVcsStatusDataForTarget }; - -const manager = createVcsStatusManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const connection = readEnvironmentConnection(environmentId as EnvironmentId); - return connection ? connection.client.vcs : null; - }, - getClientIdentity: (environmentId) => { - const connection = readEnvironmentConnection(environmentId as EnvironmentId); - return connection ? connection.environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -export function getVcsStatusSnapshot(target: VcsStatusTarget): VcsStatusState { - return manager.getSnapshot(target); -} - -export function watchVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient): () => void { - return manager.watch(target, client); -} - -export function refreshVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient) { - return manager.refresh(target, client); -} - -export function resetVcsStatusStateForTests(): void { - manager.reset(); -} - -export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - useEffect( - () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }), - [target.environmentId, target.cwd], - ); - - const state = useAtomValue( - targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, - ); - return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; -} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index ec972f9d459..7a87eb2523c 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -1,24 +1,10 @@ import { - CommandId, DEFAULT_CLIENT_SETTINGS, - DEFAULT_SERVER_SETTINGS, + type ContextMenuItem, type DesktopBridge, - EnvironmentId, - type VcsStatusResult, - ProjectId, - type OrchestrationShellStreamItem, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type ServerProvider, - type TerminalAttachStreamEvent, - type TerminalMetadataStreamEvent, - ThreadId, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { ContextMenuItem } from "@t3tools/contracts"; - const showContextMenuFallbackMock = vi.fn< ( @@ -27,344 +13,44 @@ const showContextMenuFallbackMock = ) => Promise >(); -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const terminalAttachListeners = new Set<(event: TerminalAttachStreamEvent) => void>(); -const terminalMetadataListeners = new Set<(event: TerminalMetadataStreamEvent) => void>(); -const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); -const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); - -const rpcClientMock = { - dispose: vi.fn(), - terminal: { - open: vi.fn(), - attach: vi.fn((_input: unknown, listener: (event: TerminalAttachStreamEvent) => void) => - registerListener(terminalAttachListeners, listener), - ), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - restart: vi.fn(), - close: vi.fn(), - onMetadata: vi.fn((listener: (event: TerminalMetadataStreamEvent) => void) => - registerListener(terminalMetadataListeners, listener), - ), - }, - projects: { - listEntries: vi.fn(), - readFile: vi.fn(), - searchEntries: vi.fn(), - writeFile: vi.fn(), - }, - filesystem: { - browse: vi.fn(), - }, - assets: { - createUrl: vi.fn(), - }, - preview: { - open: vi.fn(), - navigate: vi.fn(), - refresh: vi.fn(), - close: vi.fn(), - list: vi.fn(), - reportStatus: vi.fn(), - automation: { - connect: vi.fn(() => () => undefined), - respond: vi.fn(), - reportOwner: vi.fn(), - clearOwner: vi.fn(), - }, - onEvent: vi.fn(() => () => undefined), - subscribePorts: vi.fn(() => () => undefined), - }, - sourceControl: { - lookupRepository: vi.fn(), - cloneRepository: vi.fn(), - publishRepository: vi.fn(), - }, - shell: { - openInEditor: vi.fn(), - }, - vcs: { - pull: vi.fn(), - refreshStatus: vi.fn(), - onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => - registerListener(gitStatusListeners, listener), - ), - listRefs: vi.fn(), - createWorktree: vi.fn(), - removeWorktree: vi.fn(), - createRef: vi.fn(), - switchRef: vi.fn(), - init: vi.fn(), - }, - git: { - runStackedAction: vi.fn(), - resolvePullRequest: vi.fn(), - preparePullRequestThread: vi.fn(), - }, - review: { - getDiffPreview: vi.fn(), - }, - server: { - getConfig: vi.fn(), - refreshProviders: vi.fn(), - updateProvider: vi.fn(), - upsertKeybinding: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), - subscribeConfig: vi.fn(), - subscribeLifecycle: vi.fn(), - subscribeAuthAccess: vi.fn(), - }, - orchestration: { - dispatchCommand: vi.fn(), - getTurnDiff: vi.fn(), - getFullThreadDiff: vi.fn(), - subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) => - registerListener(shellStreamListeners, listener), - ), - subscribeThread: vi.fn(() => () => undefined), - }, -}; - -vi.mock("./environments/runtime", () => ({ - getPrimaryEnvironmentConnection: () => ({ - kind: "primary" as const, - knownEnvironment: { - id: "environment-local", - label: "Primary", - source: "manual" as const, - target: { - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - }, - environmentId: EnvironmentId.make("environment-local"), - }, - client: rpcClientMock, - environmentId: EnvironmentId.make("environment-local"), - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }), - resetEnvironmentServiceForTests: vi.fn(), - resetSavedEnvironmentRegistryStoreForTests: vi.fn(), - resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), - subscribeEnvironmentConnections: vi.fn(() => () => undefined), -})); - vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, })); -function emitEvent(listeners: Set<(event: T) => void>, event: T) { - for (const listener of listeners) { - listener(event); - } -} - -function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { - const testGlobal = globalThis as typeof globalThis & { - window?: Window & typeof globalThis & { desktopBridge?: unknown }; - }; - if (!testGlobal.window) { - testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; - } - return testGlobal.window; -} - function createLocalStorageStub(): Storage { - const store = new Map(); + const values = new Map(); return { - getItem: (key) => store.get(key) ?? null, + getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { - store.set(key, value); + values.set(key, value); }, removeItem: (key) => { - store.delete(key); + values.delete(key); }, - clear: () => { - store.clear(); - }, - key: (index) => [...store.keys()][index] ?? null, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, get length() { - return store.size; + return values.size; }, }; } -function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { - return { - getAppBranding: () => null, - getLocalEnvironmentBootstrap: () => null, - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - discoverSshHosts: async () => [], - ensureSshEnvironment: async () => { - throw new Error("ensureSshEnvironment not implemented in test"); - }, - disconnectSshEnvironment: async () => undefined, - fetchSshEnvironmentDescriptor: async () => { - throw new Error("fetchSshEnvironmentDescriptor not implemented in test"); - }, - bootstrapSshBearerSession: async () => { - throw new Error("bootstrapSshBearerSession not implemented in test"); - }, - fetchSshSessionState: async () => { - throw new Error("fetchSshSessionState not implemented in test"); - }, - issueSshWebSocketTicket: async () => { - throw new Error("issueSshWebSocketTicket not implemented in test"); - }, - onSshPasswordPrompt: () => () => undefined, - resolveSshPasswordPrompt: async () => undefined, - getServerExposureState: async () => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - setServerExposureMode: async () => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - setTailscaleServeEnabled: async (input) => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: input.enabled, - tailscaleServePort: input.port ?? 443, - }), - getAdvertisedEndpoints: async () => [], - pickFolder: async () => null, - confirm: async () => true, - setTheme: async () => undefined, - showContextMenu: async () => null, - openExternal: async () => true, - createCloudAuthRequest: async () => "t3code-dev://auth/callback?t3_state=test", - getCloudAuthToken: async () => null, - setCloudAuthToken: async () => true, - clearCloudAuthToken: async () => undefined, - fetchCloudAuth: async () => ({ - ok: true, - status: 200, - statusText: "OK", - headers: {}, - body: "", - }), - onCloudAuthCallback: () => () => undefined, - onMenuAction: () => () => undefined, - getUpdateState: async () => { - throw new Error("getUpdateState not implemented in test"); - }, - setUpdateChannel: async () => { - throw new Error("setUpdateChannel not implemented in test"); - }, - checkForUpdate: async () => { - throw new Error("checkForUpdate not implemented in test"); - }, - downloadUpdate: async () => { - throw new Error("downloadUpdate not implemented in test"); - }, - installUpdate: async () => { - throw new Error("installUpdate not implemented in test"); - }, - onUpdateState: () => () => undefined, - ...overrides, - }; +function testWindow(): Window & typeof globalThis { + return globalThis.window ?? (globalThis as unknown as Window & typeof globalThis); } -const defaultProviders: ReadonlyArray = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }, -]; - -const baseEnvironment = { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { - os: "darwin" as const, - arch: "arm64" as const, - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, -}; - -const baseServerConfig: ServerConfig = { - environment: baseEnvironment, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/tmp/workspace", - keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", - keybindings: [], - issues: [], - providers: defaultProviders, - availableEditors: ["cursor"], - observability: { - logsDirectoryPath: "/tmp/workspace/.config/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: DEFAULT_SERVER_SETTINGS, -}; - -const baseGitStatus: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/streamed", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - showContextMenuFallbackMock.mockReset(); - terminalAttachListeners.clear(); - terminalMetadataListeners.clear(); - shellStreamListeners.clear(); - gitStatusListeners.clear(); - const testWindow = getWindowForTest(); - Reflect.deleteProperty(testWindow, "desktopBridge"); - Object.defineProperty(testWindow, "localStorage", { + if (globalThis.window === undefined) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + } + Reflect.deleteProperty(testWindow(), "desktopBridge"); + Reflect.deleteProperty(testWindow(), "nativeApi"); + Object.defineProperty(testWindow(), "localStorage", { configurable: true, value: createLocalStorageStub(), }); @@ -374,419 +60,78 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("wsApi", () => { - it("forwards server config fetches directly to the RPC client", async () => { - rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); +describe("LocalApi", () => { + it("keeps backend operations unavailable in the browser facade", async () => { const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(); - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); - expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); - expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled(); - expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); - }); - - it("forwards terminal attach, metadata, and shell stream events", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onTerminalAttachEvent = vi.fn(); - const onTerminalMetadataEvent = vi.fn(); - const onShellEvent = vi.fn(); - - api.terminal.attach({ threadId: "thread-1", terminalId: "terminal-1" }, onTerminalAttachEvent); - api.terminal.onMetadata(onTerminalMetadataEvent); - api.orchestration.subscribeShell(onShellEvent); - - const terminalAttachEvent = { - threadId: "thread-1", - terminalId: "terminal-1", - type: "output", - data: "hello", - } satisfies TerminalAttachStreamEvent; - emitEvent(terminalAttachListeners, terminalAttachEvent); - - const terminalMetadataEvent = { - type: "upsert", - terminal: { - threadId: "thread-1", - terminalId: "terminal-1", - cwd: "/tmp/workspace", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - hasRunningSubprocess: true, - label: "terminal-1", - updatedAt: "2026-02-24T00:00:00.000Z", - }, - } satisfies TerminalMetadataStreamEvent; - emitEvent(terminalMetadataListeners, terminalMetadataEvent); - - const shellEvent = { - kind: "project-upserted" as const, - sequence: 1, - project: { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/workspace", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - scripts: [], - createdAt: "2026-02-24T00:00:00.000Z", - updatedAt: "2026-02-24T00:00:00.000Z", - }, - } satisfies OrchestrationShellStreamItem; - emitEvent(shellStreamListeners, shellEvent); - - expect(onTerminalAttachEvent).toHaveBeenCalledWith(terminalAttachEvent); - expect(onTerminalMetadataEvent).toHaveBeenCalledWith(terminalMetadataEvent); - expect(onShellEvent).toHaveBeenCalledWith(shellEvent); - }); - - it("forwards git status stream events", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onStatus = vi.fn(); - - api.vcs.onStatus({ cwd: "/repo" }, onStatus); - - const gitStatus = baseGitStatus; - emitEvent(gitStatusListeners, gitStatus); - - expect(rpcClientMock.vcs.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); - expect(onStatus).toHaveBeenCalledWith(gitStatus); - }); - - it("forwards git status refreshes directly to the RPC client", async () => { - rpcClientMock.vcs.refreshStatus.mockResolvedValue(baseGitStatus); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - - await api.vcs.refreshStatus({ cwd: "/repo" }); - - expect(rpcClientMock.vcs.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); - }); - - it("forwards shell stream subscription options to the RPC client", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onShellEvent = vi.fn(); - const onResubscribe = vi.fn(); - - api.orchestration.subscribeShell(onShellEvent, { onResubscribe }); - - expect(rpcClientMock.orchestration.subscribeShell).toHaveBeenCalledWith(onShellEvent, { - onResubscribe, - }); - }); - - it("sends orchestration dispatch commands as the direct RPC payload", async () => { - rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const command = { - type: "project.create", - commandId: CommandId.make("cmd-1"), - projectId: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt: "2026-02-24T00:00:00.000Z", - } as const; - await api.orchestration.dispatchCommand(command); - - expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command); - }); - - it("forwards workspace file writes to the project RPC", async () => { - rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.projects.writeFile({ - cwd: "/tmp/project", - relativePath: "plan.md", - contents: "# Plan\n", - }); - - expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({ - cwd: "/tmp/project", - relativePath: "plan.md", - contents: "# Plan\n", - }); - }); - - it("forwards filesystem browse requests to the RPC client", async () => { - rpcClientMock.filesystem.browse.mockResolvedValue({ - parentPath: "/tmp/project/", - entries: [], - }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.filesystem.browse({ - partialPath: "/tmp/project/", - cwd: "/tmp/project", - }); - - expect(rpcClientMock.filesystem.browse).toHaveBeenCalledWith({ - partialPath: "/tmp/project/", - cwd: "/tmp/project", - }); - }); - - it("forwards full-thread diff requests to the orchestration RPC", async () => { - rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.orchestration.getFullThreadDiff({ - threadId: ThreadId.make("thread-1"), - toTurnCount: 1, - }); - - expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({ - threadId: "thread-1", - toTurnCount: 1, - }); - }); - - it("forwards provider refreshes directly to the RPC client", async () => { - const nextProviders: ReadonlyArray = [ - { - ...defaultProviders[0]!, - checkedAt: "2026-01-03T00:00:00.000Z", - }, - ]; - rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); - expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); - }); - - it("forwards provider updates directly to the RPC client", async () => { - const nextProviders: ReadonlyArray = [ - { - ...defaultProviders[0]!, - updateState: { - status: "succeeded", - startedAt: "2026-01-03T00:00:00.000Z", - finishedAt: "2026-01-03T00:00:01.000Z", - message: "Provider updated.", - output: null, - }, - }, - ]; - rpcClientMock.server.updateProvider.mockResolvedValue({ providers: nextProviders }); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect( - api.server.updateProvider({ provider: ProviderDriverKind.make("codex") }), - ).resolves.toEqual({ - providers: nextProviders, - }); - expect(rpcClientMock.server.updateProvider).toHaveBeenCalledWith({ - provider: ProviderDriverKind.make("codex"), - }); - }); - - it("forwards server settings updates directly to the RPC client", async () => { - const nextSettings = { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }; - rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( - nextSettings, + await expect(api.server.getConfig()).rejects.toThrow( + "Local backend API is unavailable before a backend is paired.", ); - expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({ - enableAssistantStreaming: true, - }); - }); - - it("forwards context menu metadata to the desktop bridge for native menus", async () => { - const showContextMenu = vi.fn().mockResolvedValue("delete"); - getWindowForTest().desktopBridge = makeDesktopBridge({ - getClientSettings: async () => ({ - ...DEFAULT_CLIENT_SETTINGS, - contextMenuStyle: "native", - }), - showContextMenu, - }); - - const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - const items = [{ id: "delete", label: "Delete" }] as const; - - await expect(api.contextMenu.show(items)).resolves.toBe("delete"); - expect(showContextMenu).toHaveBeenCalledWith(items, undefined); - }); - - it("forwards folder picker options to the desktop bridge", async () => { - const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); - getWindowForTest().desktopBridge = makeDesktopBridge({ pickFolder }); - - const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - - await expect(api.dialogs.pickFolder({ initialPath: "/tmp/workspace" })).resolves.toBe( - "/tmp/project", + await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow( + "Local backend API is unavailable before a backend is paired.", ); - expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp/workspace" }); }); - it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { + it("uses the browser context-menu fallback without a desktop bridge", async () => { showContextMenuFallbackMock.mockResolvedValue("rename"); const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); const items = [{ id: "rename", label: "Rename" }] as const; - await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); + await expect(createLocalApi().contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); }); - it("reads and writes persistence through the desktop bridge when available", async () => { - const clientSettings = { - autoOpenPlanSidebar: false, - confirmThreadArchive: true, - confirmThreadDelete: false, - contextMenuStyle: "default" as const, - dismissedProviderUpdateNotificationKeys: [], - diffIgnoreWhitespace: true, - diffWordWrap: true, - favorites: [], - providerModelPreferences: {}, - sidebarProjectGroupingMode: "repository_path" as const, - sidebarProjectGroupingOverrides: { - "environment-local:/tmp/project": "separate" as const, - }, - sidebarProjectSortOrder: "manual" as const, - sidebarThreadSortOrder: "created_at" as const, - sidebarThreadPreviewCount: 6, - timestampFormat: "24-hour" as const, - }; + it("delegates host capabilities and persistence to the desktop bridge", async () => { + const showContextMenu = vi.fn().mockResolvedValue("delete"); + const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); const getClientSettings = vi.fn().mockResolvedValue({ - ...clientSettings, + ...DEFAULT_CLIENT_SETTINGS, + contextMenuStyle: "native", }); const setClientSettings = vi.fn().mockResolvedValue(undefined); - const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); - const setSavedEnvironmentRegistry = vi.fn().mockResolvedValue(undefined); - const getSavedEnvironmentSecret = vi.fn().mockResolvedValue("bearer-token"); - const setSavedEnvironmentSecret = vi.fn().mockResolvedValue(true); - const removeSavedEnvironmentSecret = vi.fn().mockResolvedValue(undefined); - getWindowForTest().desktopBridge = makeDesktopBridge({ + testWindow().desktopBridge = { + showContextMenu, + pickFolder, getClientSettings, setClientSettings, - getSavedEnvironmentRegistry, - setSavedEnvironmentRegistry, - getSavedEnvironmentSecret, - setSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - }); + } as unknown as DesktopBridge; const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); + const api = createLocalApi(); + const items = [{ id: "delete", label: "Delete" }] as const; - await api.persistence.getClientSettings(); - await api.persistence.setClientSettings(clientSettings); - await api.persistence.getSavedEnvironmentRegistry(); - await api.persistence.setSavedEnvironmentRegistry([]); - await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); - await api.persistence.setSavedEnvironmentSecret( - EnvironmentId.make("environment-local"), - "bearer-token", - ); - await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); + await expect(api.contextMenu.show(items)).resolves.toBe("delete"); + await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project"); + await expect(api.persistence.getClientSettings()).resolves.toEqual({ + ...DEFAULT_CLIENT_SETTINGS, + contextMenuStyle: "native", + }); + await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS); - expect(getClientSettings).toHaveBeenCalledWith(); - expect(setClientSettings).toHaveBeenCalledWith(clientSettings); - expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); - expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); - expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); - expect(setSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local", "bearer-token"); - expect(removeSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); + expect(showContextMenu).toHaveBeenCalledWith(items, undefined); + expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp" }); + expect(getClientSettings).toHaveBeenCalledTimes(2); + expect(setClientSettings).toHaveBeenCalledWith(DEFAULT_CLIENT_SETTINGS); }); - it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { + it("persists client settings in browser storage", async () => { const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - const clientSettings = { - autoOpenPlanSidebar: false, - confirmThreadArchive: true, - confirmThreadDelete: false, - contextMenuStyle: "default" as const, - dismissedProviderUpdateNotificationKeys: [], - diffIgnoreWhitespace: true, - diffWordWrap: true, - favorites: [], - providerModelPreferences: {}, - sidebarProjectGroupingMode: "repository_path" as const, - sidebarProjectGroupingOverrides: { - "environment-local:/tmp/project": "separate" as const, - }, - sidebarProjectSortOrder: "manual" as const, - sidebarThreadSortOrder: "created_at" as const, - sidebarThreadPreviewCount: 6, - timestampFormat: "24-hour" as const, + const api = createLocalApi(); + const settings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, }; - await api.persistence.setClientSettings(clientSettings); - await api.persistence.setSavedEnvironmentRegistry([ - { - environmentId: EnvironmentId.make("environment-local"), - label: "Primary", - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }, - ]); - await api.persistence.setSavedEnvironmentSecret( - EnvironmentId.make("environment-local"), - "bearer-token", - ); - - await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); - await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ - { - environmentId: EnvironmentId.make("environment-local"), - label: "Primary", - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }, - ]); - await expect( - api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), - ).resolves.toBe("bearer-token"); + await api.persistence.setClientSettings(settings); + await expect(api.persistence.getClientSettings()).resolves.toEqual(settings); + }); - await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); + it("prefers the native LocalApi when one is injected", async () => { + const nativeApi = { dialogs: {} }; + testWindow().nativeApi = nativeApi as never; + const { readLocalApi } = await import("./localApi"); - await expect( - api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), - ).resolves.toBeNull(); + expect(readLocalApi()).toBe(nativeApi); }); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 81961641670..837e44ff2bd 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -4,32 +4,10 @@ import { type ContextMenuStyle, type LocalApi, } from "@t3tools/contracts"; -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { resetVcsStatusStateForTests } from "./lib/vcsStatusState"; -import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; -import { resetServerStateForTests } from "./rpc/serverState"; -import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; -import { - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, -} from "./environments/runtime"; -import { - getPrimaryEnvironmentConnection, - resetEnvironmentServiceForTests, -} from "./environments/runtime"; -import { getPrimaryKnownEnvironment } from "./environments/primary"; import { showContextMenuFallback } from "./contextMenuFallback"; -import { - readBrowserClientSettings, - readBrowserSavedEnvironmentRegistry, - readBrowserSavedEnvironmentSecret, - removeBrowserSavedEnvironmentSecret, - writeBrowserClientSettings, - writeBrowserSavedEnvironmentRegistry, - writeBrowserSavedEnvironmentSecret, -} from "./clientPersistenceStorage"; +import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; import { isMacPlatform } from "./lib/utils"; let cachedApi: LocalApi | undefined; @@ -60,7 +38,7 @@ function shouldUseNativeContextMenu(style: ContextMenuStyle): boolean { return Boolean(window.desktopBridge) && isMacPlatform(platform); } -function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { +function createBrowserLocalApi(): LocalApi { return { dialogs: { pickFolder: async (options) => { @@ -75,10 +53,7 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { }, }, shell: { - openInEditor: (cwd, editor) => - rpcClient - ? rpcClient.shell.openInEditor({ cwd, editor }) - : Promise.reject(unavailableLocalBackendError()), + openInEditor: () => Promise.reject(unavailableLocalBackendError()), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); @@ -120,88 +95,26 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { } writeBrowserClientSettings(settings); }, - getSavedEnvironmentRegistry: async () => { - if (window.desktopBridge) { - return window.desktopBridge.getSavedEnvironmentRegistry(); - } - return readBrowserSavedEnvironmentRegistry(); - }, - setSavedEnvironmentRegistry: async (records) => { - if (window.desktopBridge) { - return window.desktopBridge.setSavedEnvironmentRegistry(records); - } - writeBrowserSavedEnvironmentRegistry(records); - }, - getSavedEnvironmentSecret: async (environmentId) => { - if (window.desktopBridge) { - return window.desktopBridge.getSavedEnvironmentSecret(environmentId); - } - return readBrowserSavedEnvironmentSecret(environmentId); - }, - setSavedEnvironmentSecret: async (environmentId, secret) => { - if (window.desktopBridge) { - return window.desktopBridge.setSavedEnvironmentSecret(environmentId, secret); - } - return writeBrowserSavedEnvironmentSecret(environmentId, secret); - }, - removeSavedEnvironmentSecret: async (environmentId) => { - if (window.desktopBridge) { - return window.desktopBridge.removeSavedEnvironmentSecret(environmentId); - } - removeBrowserSavedEnvironmentSecret(environmentId); - }, }, server: { - getConfig: () => - rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), - refreshProviders: () => - rpcClient - ? rpcClient.server.refreshProviders() - : Promise.reject(unavailableLocalBackendError()), - updateProvider: (input) => - rpcClient - ? rpcClient.server.updateProvider(input) - : Promise.reject(unavailableLocalBackendError()), - upsertKeybinding: (input) => - rpcClient - ? rpcClient.server.upsertKeybinding(input) - : Promise.reject(unavailableLocalBackendError()), - removeKeybinding: (input) => - rpcClient - ? rpcClient.server.removeKeybinding(input) - : Promise.reject(unavailableLocalBackendError()), - getSettings: () => - rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), - updateSettings: (patch) => - rpcClient - ? rpcClient.server.updateSettings(patch) - : Promise.reject(unavailableLocalBackendError()), - discoverSourceControl: () => - rpcClient - ? rpcClient.server.discoverSourceControl() - : Promise.reject(unavailableLocalBackendError()), - getTraceDiagnostics: () => - rpcClient - ? rpcClient.server.getTraceDiagnostics() - : Promise.reject(unavailableLocalBackendError()), - getProcessDiagnostics: () => - rpcClient - ? rpcClient.server.getProcessDiagnostics() - : Promise.reject(unavailableLocalBackendError()), - getProcessResourceHistory: (input) => - rpcClient - ? rpcClient.server.getProcessResourceHistory(input) - : Promise.reject(unavailableLocalBackendError()), - signalProcess: (input) => - rpcClient - ? rpcClient.server.signalProcess(input) - : Promise.reject(unavailableLocalBackendError()), + getConfig: () => Promise.reject(unavailableLocalBackendError()), + refreshProviders: () => Promise.reject(unavailableLocalBackendError()), + updateProvider: () => Promise.reject(unavailableLocalBackendError()), + upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()), + removeKeybinding: () => Promise.reject(unavailableLocalBackendError()), + getSettings: () => Promise.reject(unavailableLocalBackendError()), + updateSettings: () => Promise.reject(unavailableLocalBackendError()), + discoverSourceControl: () => Promise.reject(unavailableLocalBackendError()), + getTraceDiagnostics: () => Promise.reject(unavailableLocalBackendError()), + getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()), + getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), + signalProcess: () => Promise.reject(unavailableLocalBackendError()), }, }; } -export function createLocalApi(rpcClient: WsRpcClient): LocalApi { - return createBrowserLocalApi(rpcClient); +export function createLocalApi(): LocalApi { + return createBrowserLocalApi(); } export function readLocalApi(): LocalApi | undefined { @@ -213,10 +126,7 @@ export function readLocalApi(): LocalApi | undefined { return cachedApi; } - const primaryEnvironment = getPrimaryKnownEnvironment(); - cachedApi = primaryEnvironment - ? createLocalApi(getPrimaryEnvironmentConnection().client) - : createBrowserLocalApi(); + cachedApi = createBrowserLocalApi(); return cachedApi; } @@ -232,12 +142,5 @@ export async function __resetLocalApiForTests() { cachedApi = undefined; const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); __resetClientSettingsPersistenceForTests(); - await resetEnvironmentServiceForTests(); - resetVcsStatusStateForTests(); - resetSourceControlDiscoveryStateForTests(); resetRequestLatencyStateForTests(); - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); - resetServerStateForTests(); - resetWsConnectionStateForTests(); } diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 72415d57de0..8204222b3b0 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,172 +1,14 @@ -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; -import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; -import type { UnifiedSettings } from "@t3tools/contracts/settings"; -import { normalizeProjectPathForComparison } from "./lib/projectPaths"; -import type { Project } from "./types"; - -export interface ProjectGroupingSettings { - sidebarProjectGroupingMode: SidebarProjectGroupingMode; - sidebarProjectGroupingOverrides: Record; -} - -export type ProjectGroupingMode = SidebarProjectGroupingMode; - -export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings { - return { - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - }; -} - -function uniqueNonEmptyValues(values: ReadonlyArray): string[] { - const seen = new Set(); - const unique: string[] = []; - for (const value of values) { - const trimmed = value?.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - unique.push(trimmed); - } - return unique; -} - -function deriveRepositoryRelativeProjectPath( - project: Pick, -): string | null { - const rootPath = project.repositoryIdentity?.rootPath?.trim(); - if (!rootPath) { - return null; - } - - const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); - const normalizedRootPath = normalizeProjectPathForComparison(rootPath); - if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { - return null; - } - - if (normalizedProjectPath === normalizedRootPath) { - return ""; - } - - const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; - const rootPrefix = `${normalizedRootPath}${separator}`; - if (!normalizedProjectPath.startsWith(rootPrefix)) { - return null; - } - - return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); -} - -export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { - return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; -} - -export function derivePhysicalProjectKey(project: Pick): string { - return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); -} - -export function deriveProjectGroupingOverrideKey( - project: Pick, -): string { - return derivePhysicalProjectKey(project); -} - -// Key under which a project's manual sort order (projectOrder) is stored. -// Must stay aligned with the writer side in `uiStateStore.syncProjects` and -// the drag handlers in `Sidebar` so readers and writers agree. -export function getProjectOrderKey(project: Pick): string { - return derivePhysicalProjectKey(project); -} - -export function resolveProjectGroupingMode( - project: Pick, - settings: ProjectGroupingSettings, -): SidebarProjectGroupingMode { - return ( - settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? - settings.sidebarProjectGroupingMode - ); -} - -function deriveRepositoryScopedKey( - project: Pick, - groupingMode: SidebarProjectGroupingMode, -): string | null { - const canonicalKey = project.repositoryIdentity?.canonicalKey; - if (!canonicalKey) { - return null; - } - - if (groupingMode === "repository") { - return canonicalKey; - } - - const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); - if (relativeProjectPath === null) { - return canonicalKey; - } - - return relativeProjectPath.length === 0 - ? canonicalKey - : `${canonicalKey}::${relativeProjectPath}`; -} - -export function deriveLogicalProjectKey( - project: Pick, - options?: { - groupingMode?: SidebarProjectGroupingMode; - }, -): string { - const groupingMode = options?.groupingMode ?? "repository"; - if (groupingMode === "separate") { - return derivePhysicalProjectKey(project); - } - - return ( - deriveRepositoryScopedKey(project, groupingMode) ?? - derivePhysicalProjectKey(project) ?? - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) - ); -} - -export function deriveLogicalProjectKeyFromSettings( - project: Pick, - settings: ProjectGroupingSettings, -): string { - return deriveLogicalProjectKey(project, { - groupingMode: resolveProjectGroupingMode(project, settings), - }); -} - -export function deriveLogicalProjectKeyFromRef( - projectRef: ScopedProjectRef, - project: Pick | null | undefined, - options?: { - groupingMode?: SidebarProjectGroupingMode; - }, -): string { - return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); -} - -export function deriveProjectGroupLabel(input: { - representative: Pick; - members: ReadonlyArray>; -}): string { - const sharedDisplayNames = uniqueNonEmptyValues( - input.members.map((member) => member.repositoryIdentity?.displayName), - ); - if (sharedDisplayNames.length === 1) { - return sharedDisplayNames[0]!; - } - - const sharedRepositoryNames = uniqueNonEmptyValues( - input.members.map((member) => member.repositoryIdentity?.name), - ); - if (sharedRepositoryNames.length === 1) { - return sharedRepositoryNames[0]!; - } - - return input.representative.name; -} +export { + deriveLogicalProjectKey, + deriveLogicalProjectKeyFromRef, + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + derivePhysicalProjectKeyFromPath, + deriveProjectGroupLabel, + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + resolveProjectGroupingMode, + selectProjectGroupingSettings, + type ProjectGroupingMode, + type ProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6d3b18578c3..dcb6d28a0ce 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,7 +1,8 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { ClerkProvider } from "@clerk/react"; -import { RouterProvider } from "@tanstack/react-router"; +import { passkeys } from "@clerk/electron/passkeys"; +import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "@fontsource-variable/dm-sans/index.css"; @@ -11,14 +12,12 @@ import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; -import { DesktopClerkProvider } from "./cloud/desktopClerk"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; -import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; import { BASE_PATH } from "./basePath"; -import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { AppRoot } from "./AppRoot"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -29,24 +28,17 @@ if (isElectron) { syncDocumentWindowControlsOverlayClass(); } -document.title = APP_DISPLAY_NAME; - const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -const app = ( - <> - - - -); +const app = ; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {clerkPublishableKey && hasCloudPublicConfig() ? ( isElectron ? ( - + {app} - + ) : ( {app} diff --git a/apps/web/src/modelPickerOpenState.ts b/apps/web/src/modelPickerOpenState.ts deleted file mode 100644 index 5a4993c16e7..00000000000 --- a/apps/web/src/modelPickerOpenState.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { create } from "zustand"; - -const useModelPickerOpenStore = create<{ - open: boolean; - setOpen: (open: boolean) => void; -}>((set) => ({ - open: false, - setOpen: (open) => set((current) => (current.open === open ? current : { open })), -})); - -export function useModelPickerOpen(): boolean { - return useModelPickerOpenStore((store) => store.open); -} - -export function setModelPickerOpen(open: boolean): void { - useModelPickerOpenStore.getState().setOpen(open); -} diff --git a/apps/web/src/modelPickerVisibility.ts b/apps/web/src/modelPickerVisibility.ts new file mode 100644 index 00000000000..85145ac76cf --- /dev/null +++ b/apps/web/src/modelPickerVisibility.ts @@ -0,0 +1,13 @@ +const MODEL_PICKER_CONTENT_SELECTOR = "[data-model-picker-content]"; + +/** + * Model-picker visibility is already represented by the mounted popover. + * Shortcut arbitration reads that source directly instead of mirroring it in + * a second React or external store. + */ +export function isModelPickerOpen(): boolean { + return ( + typeof document !== "undefined" && + document.querySelector(MODEL_PICKER_CONTENT_SELECTOR) !== null + ); +} diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 0fcf680b732..7ede9665ac9 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -303,7 +303,6 @@ export function resolveAppModelSelectionState( provider, model, models: entry.models, - prompt: "", modelOptions: selectedEntry ? selection.options : undefined, }); @@ -321,7 +320,6 @@ export function resolveAppModelSelectionState( provider, model, models: getProviderModels(providers, provider), - prompt: "", modelOptions: keptSelectedProvider ? selection.options : undefined, }); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index c0d104ac517..27d348019e2 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -3,10 +3,13 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Scope from "effect/Scope"; import * as Tracer from "effect/Tracer"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { HttpClient } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; @@ -21,7 +24,7 @@ const CLIENT_TRACING_RESOURCE = { } as const; const delegateRuntimeLayer = Layer.mergeAll( - FetchHttpClient.layer, + primaryEnvironmentHttpLayer, OtlpSerialization.layerJson, Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); @@ -78,8 +81,8 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise + runtime.runPromiseExit( Scope.provide(scope)( OtlpTracer.make({ url: otlpTracesUrl, @@ -87,26 +90,33 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise undefined) - .finally(() => { - runtime.dispose(); - }); -} - -function formatError(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - return String(error); + await settleAsyncResult(() => runtime.runPromiseExit(Scope.close(scope, Exit.void))); + runtime.dispose(); } export async function __resetClientTracingForTests() { diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index 8b7c4b1a1dc..014d220860d 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -1,55 +1,20 @@ -import type { - DiscoveredLocalServer, - EnvironmentApi, - EnvironmentId, - ThreadId, -} from "@t3tools/contracts"; +import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; -import { create } from "zustand"; -const EMPTY_PORTS: ReadonlyArray = Object.freeze([]); - -interface PortDiscoveryState { - readonly byEnvironment: Record>; - setPorts: (environmentId: EnvironmentId, ports: ReadonlyArray) => void; - clearEnvironment: (environmentId: EnvironmentId) => void; - reset: () => void; -} +import { previewEnvironment } from "./state/preview"; +import { useEnvironmentQuery } from "./state/query"; -export const usePortDiscoveryStore = create((set) => ({ - byEnvironment: {}, - setPorts: (environmentId, ports) => - set((state) => ({ - byEnvironment: { - ...state.byEnvironment, - [environmentId]: ports, - }, - })), - clearEnvironment: (environmentId) => - set((state) => { - if (!(environmentId in state.byEnvironment)) return state; - const { [environmentId]: _removed, ...byEnvironment } = state.byEnvironment; - return { byEnvironment }; - }), - reset: () => set({ byEnvironment: {} }), -})); - -export function subscribePortDiscovery(input: { - readonly environmentId: EnvironmentId; - readonly previewApi: Pick; -}): () => void { - usePortDiscoveryStore.getState().clearEnvironment(input.environmentId); - return input.previewApi.subscribePorts((snapshot) => { - usePortDiscoveryStore.getState().setPorts(input.environmentId, snapshot.servers); - }); -} +const EMPTY_PORTS: ReadonlyArray = Object.freeze([]); export function useDiscoveredPorts( environmentId: EnvironmentId | null, ): ReadonlyArray { - return usePortDiscoveryStore( - (state) => (environmentId ? state.byEnvironment[environmentId] : undefined) ?? EMPTY_PORTS, + const query = useEnvironmentQuery( + environmentId === null + ? null + : previewEnvironment.discoveredServers({ environmentId, input: {} }), ); + return query.data?.servers ?? EMPTY_PORTS; } export function useThreadDiscoveredPorts(input: { diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index b2df246f246..d2bf2e7c260 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -1,11 +1,25 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore"; +import { + __testing, + applyPreviewDesktopState, + applyPreviewServerEvent, + applyPreviewServerSnapshot, + beginPreviewSessionClose, + cancelPreviewSessionClose, + previewStateAtom, + readThreadPreviewState, + rememberPreviewUrl, + removePreviewThread, + resetPreviewStateForTests, + setActivePreviewTab, +} from "./previewStateStore"; const environmentId = "env-1" as EnvironmentId; const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); +const otherRef = scopeThreadRef(environmentId, ThreadId.make("thread-2")); const makeSnapshot = (overrides: Partial = {}): PreviewSessionSnapshot => ({ threadId: "thread-1", @@ -18,20 +32,31 @@ const makeSnapshot = (overrides: Partial = {}): PreviewS }); beforeEach(() => { - usePreviewStateStore.setState({ byThreadKey: {} }); + resetPreviewStateForTests(); }); describe("previewStateStore (single-tab)", () => { + it("keeps independent state atoms for each thread", () => { + expect(previewStateAtom(scopedThreadKey(ref))).toBe(previewStateAtom(scopedThreadKey(ref))); + expect(previewStateAtom(scopedThreadKey(ref))).not.toBe( + previewStateAtom(scopedThreadKey(otherRef)), + ); + + applyPreviewServerSnapshot(ref, makeSnapshot()); + expect(readThreadPreviewState(ref).snapshot?.tabId).toBe("tab_a"); + expect(readThreadPreviewState(otherRef)).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); + }); + it("opened event seeds the snapshot and remembers the URL", () => { const snapshot = makeSnapshot(); - usePreviewStateStore.getState().applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.tabId).toBe(snapshot.tabId); expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); }); @@ -39,36 +64,34 @@ describe("previewStateStore (single-tab)", () => { it("a second `opened` for a different tab replaces the rendered snapshot", () => { const a = makeSnapshot({ tabId: "tab_a" }); const b = makeSnapshot({ tabId: "tab_b" }); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: a.tabId, createdAt: a.updatedAt, snapshot: a, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: b.tabId, createdAt: b.updatedAt, snapshot: b, }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.tabId).toBe(b.tabId); }); it("navigated event updates the snapshot URL", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "navigated", threadId: "thread-1", tabId: snapshot.tabId, @@ -78,7 +101,7 @@ describe("previewStateStore (single-tab)", () => { navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" }, }, }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.navStatus._tag).toBe("Success"); if (state.snapshot?.navStatus._tag === "Success") { expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about"); @@ -87,15 +110,14 @@ describe("previewStateStore (single-tab)", () => { it("failed event flips the snapshot to LoadFailed when tabId matches", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "failed", threadId: "thread-1", tabId: snapshot.tabId, @@ -105,21 +127,20 @@ describe("previewStateStore (single-tab)", () => { code: -105, description: "ERR_NAME_NOT_RESOLVED", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.navStatus._tag).toBe("LoadFailed"); }); it("failed event for a non-active tab is ignored", () => { const snapshot = makeSnapshot({ tabId: "tab_a" }); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "failed", threadId: "thread-1", tabId: "tab_b", @@ -129,27 +150,26 @@ describe("previewStateStore (single-tab)", () => { code: -105, description: "ERR_NAME_NOT_RESOLVED", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.navStatus._tag).toBe("Loading"); }); it("closed event clears snapshot but retains recently-seen URLs", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "closed", threadId: "thread-1", tabId: snapshot.tabId, createdAt: "2026-01-01T00:00:01.000Z", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot).toBeNull(); expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); }); @@ -160,13 +180,12 @@ describe("previewStateStore (single-tab)", () => { tabId: "tab_b", updatedAt: "2026-01-01T00:00:01.000Z", }); - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, first); - store.applyServerSnapshot(ref, second); + applyPreviewServerSnapshot(ref, first); + applyPreviewServerSnapshot(ref, second); - store.removeSession(ref, second.tabId); + beginPreviewSessionClose(ref, second.tabId); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(Object.keys(state.sessions)).toEqual([first.tabId]); expect(state.activeTabId).toBe(first.tabId); expect(state.snapshot?.tabId).toBe(first.tabId); @@ -174,60 +193,81 @@ describe("previewStateStore (single-tab)", () => { it("treats a late server close event after optimistic removal as a no-op", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, snapshot); - store.removeSession(ref, snapshot.tabId); + applyPreviewServerSnapshot(ref, snapshot); + beginPreviewSessionClose(ref, snapshot.tabId); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "closed", threadId: "thread-1", tabId: snapshot.tabId, createdAt: "2026-01-01T00:00:01.000Z", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); + expect(state.sessions).toEqual({}); + expect(state.snapshot).toBeNull(); + }); + + it("does not resurrect an intentionally closed tab from a stale list snapshot", () => { + const snapshot = makeSnapshot(); + applyPreviewServerSnapshot(ref, snapshot); + beginPreviewSessionClose(ref, snapshot.tabId); + + applyPreviewServerSnapshot(ref, snapshot); + + const state = readThreadPreviewState(ref); expect(state.sessions).toEqual({}); expect(state.snapshot).toBeNull(); }); + it("can restore a suppressed tab after a failed close", () => { + const snapshot = makeSnapshot(); + applyPreviewServerSnapshot(ref, snapshot); + beginPreviewSessionClose(ref, snapshot.tabId); + + cancelPreviewSessionClose(ref, snapshot, snapshot.tabId); + + const state = readThreadPreviewState(ref); + expect(state.sessions).toEqual({ [snapshot.tabId]: snapshot }); + expect(state.snapshot).toEqual(snapshot); + }); + it("closed event for a different tab is a no-op", () => { const snapshot = makeSnapshot({ tabId: "tab_a" }); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "closed", threadId: "thread-1", tabId: "tab_b", createdAt: "2026-01-01T00:00:01.000Z", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.tabId).toBe(snapshot.tabId); }); it("desktopOverlay updates independently of snapshot", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyDesktopState(ref, snapshot.tabId, { + applyPreviewDesktopState(ref, snapshot.tabId, { canGoBack: true, canGoForward: false, loading: false, zoomFactor: 1, controller: "none", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.desktopOverlay?.canGoBack).toBe(true); expect(state.snapshot?.canGoBack).toBe(false); }); @@ -235,19 +275,18 @@ describe("previewStateStore (single-tab)", () => { it("retains multiple tabs and switches active desktop state", () => { const first = makeSnapshot(); const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" }; - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, first); - store.applyServerSnapshot(ref, second); - store.applyDesktopState(ref, first.tabId, { + applyPreviewServerSnapshot(ref, first); + applyPreviewServerSnapshot(ref, second); + applyPreviewDesktopState(ref, first.tabId, { canGoBack: true, canGoForward: false, loading: false, zoomFactor: 1, controller: "none", }); - store.setActiveTab(ref, first.tabId); + setActivePreviewTab(ref, first.tabId); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]); expect(state.snapshot?.tabId).toBe(first.tabId); expect(state.desktopOverlay?.canGoBack).toBe(true); @@ -255,23 +294,21 @@ describe("previewStateStore (single-tab)", () => { it("applyServerSnapshot null clears snapshot for a thread that had one", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, snapshot); - store.applyServerSnapshot(ref, null); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + applyPreviewServerSnapshot(ref, snapshot); + applyPreviewServerSnapshot(ref, null); + const state = readThreadPreviewState(ref); expect(state.snapshot).toBeNull(); }); it("does not replace a streamed snapshot with older SWR data", () => { - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot( + applyPreviewServerSnapshot( ref, makeSnapshot({ navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" }, updatedAt: "2026-01-01T00:00:02.000Z", }), ); - store.applyServerSnapshot( + applyPreviewServerSnapshot( ref, makeSnapshot({ navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" }, @@ -279,7 +316,7 @@ describe("previewStateStore (single-tab)", () => { }), ); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.navStatus).toEqual({ _tag: "Success", url: "http://localhost:5173/new", @@ -288,11 +325,10 @@ describe("previewStateStore (single-tab)", () => { }); it("rememberUrl dedupes and caps at limit", () => { - const store = usePreviewStateStore.getState(); for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { - store.rememberUrl(ref, `http://localhost:${5000 + i}/`); + rememberPreviewUrl(ref, `http://localhost:${5000 + i}/`); } - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT); expect(state.recentlySeenUrls[0]).toBe( `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, @@ -301,10 +337,9 @@ describe("previewStateStore (single-tab)", () => { it("removeThread strips the entry", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, snapshot); - store.removeThread(ref); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + applyPreviewServerSnapshot(ref, snapshot); + removePreviewThread(ref); + const state = readThreadPreviewState(ref); expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); }); }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index ab99ce63bb8..7f8f8576130 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -1,25 +1,21 @@ /** * Per-thread preview UI state. * - * Single-tab model: one snapshot per thread, mirrored two ways: - * - `snapshot` is the server-authoritative URL/title/load-status, replayed - * on WS reconnect so the panel survives backend restarts. - * - `desktopOverlay` is low-latency state from the local - * (canGoBack/canGoForward/visible/zoom/loading), used by the chrome row's - * button enablement. - * - * The schema-level `tabId` exists because the server still keys sessions by - * `(threadId, tabId)`; the client just always tracks one and ignores the rest. + * Each thread owns an independent atom. Most consumers read exactly one + * thread; the desktop browser host uses the aggregate session atom because it + * is the one place that must enumerate every live preview tab. */ -import { scopedThreadKey } from "@t3tools/client-runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type PreviewEvent, type PreviewSessionSnapshot, type ScopedThreadRef, } from "@t3tools/contracts"; -import { create } from "zustand"; +import { Atom } from "effect/unstable/reactivity"; import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; +import { appAtomRegistry } from "./rpc/atomRegistry"; export interface DesktopPreviewOverlay { canGoBack: boolean; @@ -32,72 +28,84 @@ export interface DesktopPreviewOverlay { export interface ThreadPreviewState { snapshot: PreviewSessionSnapshot | null; sessions: Record; + /** Tabs intentionally closed by this client. Stale list snapshots must not resurrect them. */ + suppressedTabIds: ReadonlySet; activeTabId: string | null; - /** Bridge state takes precedence over `snapshot` for nav button enablement. */ desktopOverlay: DesktopPreviewOverlay | null; desktopByTabId: Record; - /** Recently-visited URLs surfaced in the empty state. */ recentlySeenUrls: string[]; } const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ snapshot: null, sessions: {}, + suppressedTabIds: new Set(), activeTabId: null, desktopOverlay: null, desktopByTabId: {}, recentlySeenUrls: [] as string[], }); -const revisionByThreadKey = new Map(); +const emptyPreviewStateAtom = Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( + Atom.withLabel("preview:empty-thread"), +); -const bumpPreviewStateRevision = (threadKey: string): void => { - revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1); -}; +export const previewStateAtom = Atom.family((threadKey: string) => + Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`preview:thread:${threadKey}`), + ), +); -export function readPreviewStateRevision(ref: ScopedThreadRef): number { - return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0; +// Only the Electron browser host needs a cross-thread view. Keep that index +// separate so thread-local readers never subscribe to unrelated previews. +interface ActivePreviewThreadIndex { + readonly keys: ReadonlySet; } -export interface PreviewStateStoreState { - byThreadKey: Record; - applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; - applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; - applyDesktopState: ( - ref: ScopedThreadRef, - tabId: string, - overlay: DesktopPreviewOverlay | null, - ) => void; - removeSession: (ref: ScopedThreadRef, tabId: string) => void; - setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; - rememberUrl: (ref: ScopedThreadRef, url: string) => void; - removeThread: (ref: ScopedThreadRef) => void; -} +const activePreviewThreadKeysAtom = Atom.make({ + keys: new Set(), +}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-thread-keys")); -const ensureState = ( - byThreadKey: Record, - threadKey: string, -): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE; +const activePreviewSessionsAtom = Atom.make((get) => { + const byThreadKey: Record = {}; + for (const threadKey of get(activePreviewThreadKeysAtom).keys) { + const state = get(previewStateAtom(threadKey)); + if (Object.keys(state.sessions).length > 0) { + byThreadKey[threadKey] = state; + } + } + return byThreadKey; +}).pipe(Atom.withLabel("preview:active-sessions")); -const updateThread = ( - state: PreviewStateStoreState, - threadKey: string, - updater: (current: ThreadPreviewState) => ThreadPreviewState, -): PreviewStateStoreState["byThreadKey"] => { - const current = ensureState(state.byThreadKey, threadKey); - const next = updater(current); - if (next === current) return state.byThreadKey; - return { ...state.byThreadKey, [threadKey]: next }; -}; +const changedPreviewThreadKeys = new Set(); -const removeThreadKey = ( - byThreadKey: Record, - threadKey: string, -): Record => { - if (!(threadKey in byThreadKey)) return byThreadKey; - const { [threadKey]: _removed, ...rest } = byThreadKey; - return rest; -}; +function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): void { + const active = Object.keys(state.sessions).length > 0; + appAtomRegistry.update(activePreviewThreadKeysAtom, (current) => { + if (current.keys.has(threadKey) === active) return current; + const next = new Set(current.keys); + if (active) next.add(threadKey); + else next.delete(threadKey); + return { keys: next }; + }); +} + +function updateThreadPreviewState( + ref: ScopedThreadRef, + update: (current: ThreadPreviewState) => ThreadPreviewState, +): void { + const threadKey = scopedThreadKey(ref); + const atom = previewStateAtom(threadKey); + let nextState = appAtomRegistry.get(atom); + const changed = appAtomRegistry.modify(atom, (current) => { + nextState = update(current); + return [nextState !== current, nextState]; + }); + if (!changed) return; + changedPreviewThreadKeys.add(threadKey); + syncActivePreviewThread(threadKey, nextState); +} const dedupeRecentUrls = (existing: string[], url: string): string[] => { const next = [url, ...existing.filter((entry) => entry !== url)]; @@ -125,164 +133,198 @@ const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPrevie }; }; -export const usePreviewStateStore = create()((set) => ({ - byThreadKey: {}, - applyServerEvent: (ref, event) => - set((state) => { - const threadKey = scopedThreadKey(ref); - bumpPreviewStateRevision(threadKey); - let nextByThread = state.byThreadKey; - switch (event.type) { - case "opened": - case "navigated": - nextByThread = updateThread(state, threadKey, (current) => { - const snapshot = event.snapshot; - const recentlySeenUrls = - snapshot.navStatus._tag === "Idle" - ? current.recentlySeenUrls - : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); - const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; - const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; - const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; - return { - ...current, - sessions, - activeTabId: activeTabId ?? snapshot.tabId, - snapshot: activeSnapshot, - desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, - recentlySeenUrls, - }; - }); - break; - case "failed": - nextByThread = updateThread(state, threadKey, (current) => { - const existing = current.sessions[event.tabId]; - if (!existing) return current; - const failedSnapshot = { - ...existing, - navStatus: { - _tag: "LoadFailed" as const, - url: event.url, - title: event.title, - code: event.code, - description: event.description, - }, - updatedAt: event.createdAt, - }; - const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; - return { - ...current, - sessions, - snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, - }; - }); - break; - case "closed": - nextByThread = updateThread(state, threadKey, (current) => - removeSession(current, event.tabId), - ); - break; - } - return { byThreadKey: nextByThread }; - }), - applyServerSnapshot: (ref, snapshot) => - set((state) => { - const threadKey = scopedThreadKey(ref); - bumpPreviewStateRevision(threadKey); - const nextByThread = updateThread(state, threadKey, (current) => { - if (!snapshot && current.snapshot === null) return current; - if (!snapshot) { - return { - ...current, - snapshot: null, - sessions: {}, - activeTabId: null, - desktopOverlay: null, - desktopByTabId: {}, - }; - } - const existing = current.sessions[snapshot.tabId]; - if (existing && existing.updatedAt > snapshot.updatedAt) { - return current; - } +export function useThreadPreviewState(ref: ScopedThreadRef | null | undefined): ThreadPreviewState { + const atom = ref ? previewStateAtom(scopedThreadKey(ref)) : emptyPreviewStateAtom; + return useAtomValue(atom); +} + +export function useActivePreviewSessions(): Record { + return useAtomValue(activePreviewSessionsAtom); +} + +export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState { + return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); +} + +export function subscribeThreadPreviewState( + ref: ScopedThreadRef, + listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, +): () => void { + const atom = previewStateAtom(scopedThreadKey(ref)); + let previous = appAtomRegistry.get(atom); + return appAtomRegistry.subscribe(atom, (state) => { + const prior = previous; + previous = state; + listener(state, prior); + }); +} + +export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { + updateThreadPreviewState(ref, (current) => { + switch (event.type) { + case "opened": + case "navigated": { + const snapshot = event.snapshot; + if (current.suppressedTabIds.has(snapshot.tabId)) return current; const recentlySeenUrls = - snapshot && snapshot.navStatus._tag !== "Idle" - ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) - : current.recentlySeenUrls; + snapshot.navStatus._tag === "Idle" + ? current.recentlySeenUrls + : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); + const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; + const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; + const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; return { ...current, - snapshot, - sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, - activeTabId: snapshot.tabId, - desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, + sessions, + activeTabId: activeTabId ?? snapshot.tabId, + snapshot: activeSnapshot, + desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, recentlySeenUrls, }; - }); - return { byThreadKey: nextByThread }; - }), - applyDesktopState: (ref, tabId, overlay) => - set((state) => { - const threadKey = scopedThreadKey(ref); - const nextByThread = updateThread(state, threadKey, (current) => { - const desktopByTabId = { ...current.desktopByTabId }; - if (overlay) desktopByTabId[tabId] = overlay; - else delete desktopByTabId[tabId]; - return { - ...current, - desktopByTabId, - desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, + } + case "failed": { + const existing = current.sessions[event.tabId]; + if (!existing) return current; + const failedSnapshot = { + ...existing, + navStatus: { + _tag: "LoadFailed" as const, + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, }; - }); - return { byThreadKey: nextByThread }; - }), - removeSession: (ref, tabId) => - set((state) => { - const threadKey = scopedThreadKey(ref); - bumpPreviewStateRevision(threadKey); - return { - byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), - }; - }), - setActiveTab: (ref, tabId) => - set((state) => { - const threadKey = scopedThreadKey(ref); - const nextByThread = updateThread(state, threadKey, (current) => { - const snapshot = current.sessions[tabId]; - if (!snapshot || current.activeTabId === tabId) return current; + const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; return { ...current, - activeTabId: tabId, - snapshot, - desktopOverlay: current.desktopByTabId[tabId] ?? null, + sessions, + snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, }; - }); - return { byThreadKey: nextByThread }; - }), - rememberUrl: (ref, url) => - set((state) => { - if (url.trim().length === 0) return state; - const threadKey = scopedThreadKey(ref); - const nextByThread = updateThread(state, threadKey, (current) => ({ + } + case "closed": + return removeSession(current, event.tabId); + } + }); +} + +export function applyPreviewServerSnapshot( + ref: ScopedThreadRef, + snapshot: PreviewSessionSnapshot | null, +): void { + updateThreadPreviewState(ref, (current) => { + if (!snapshot && current.snapshot === null) return current; + if (!snapshot) { + return { ...current, - recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), - })); - return { byThreadKey: nextByThread }; - }), - removeThread: (ref) => - set((state) => { - const threadKey = scopedThreadKey(ref); - bumpPreviewStateRevision(threadKey); - if (!(threadKey in state.byThreadKey)) return state; - return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) }; - }), -})); + snapshot: null, + sessions: {}, + activeTabId: null, + desktopOverlay: null, + desktopByTabId: {}, + }; + } + if (current.suppressedTabIds.has(snapshot.tabId)) return current; + const existing = current.sessions[snapshot.tabId]; + if (existing && existing.updatedAt > snapshot.updatedAt) return current; + const recentlySeenUrls = + snapshot.navStatus._tag !== "Idle" + ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) + : current.recentlySeenUrls; + return { + ...current, + snapshot, + sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, + activeTabId: snapshot.tabId, + desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, + recentlySeenUrls, + }; + }); +} + +export function applyPreviewDesktopState( + ref: ScopedThreadRef, + tabId: string, + overlay: DesktopPreviewOverlay | null, +): void { + updateThreadPreviewState(ref, (current) => { + const desktopByTabId = { ...current.desktopByTabId }; + if (overlay) desktopByTabId[tabId] = overlay; + else delete desktopByTabId[tabId]; + return { + ...current, + desktopByTabId, + desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, + }; + }); +} + +export function beginPreviewSessionClose(ref: ScopedThreadRef, tabId: string): void { + updateThreadPreviewState(ref, (current) => { + const suppressedTabIds = new Set(current.suppressedTabIds); + suppressedTabIds.add(tabId); + return { + ...removeSession(current, tabId), + suppressedTabIds, + }; + }); +} + +export function cancelPreviewSessionClose( + ref: ScopedThreadRef, + snapshot: PreviewSessionSnapshot | null, + tabId: string, +): void { + updateThreadPreviewState(ref, (current) => { + if (!current.suppressedTabIds.has(tabId)) return current; + const suppressedTabIds = new Set(current.suppressedTabIds); + suppressedTabIds.delete(tabId); + if (!snapshot) { + return { ...current, suppressedTabIds }; + } + const recentlySeenUrls = + snapshot.navStatus._tag !== "Idle" + ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) + : current.recentlySeenUrls; + return { + ...current, + snapshot, + sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, + suppressedTabIds, + activeTabId: snapshot.tabId, + desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, + recentlySeenUrls, + }; + }); +} + +export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { + updateThreadPreviewState(ref, (current) => { + const snapshot = current.sessions[tabId]; + if (!snapshot || current.activeTabId === tabId) return current; + return { + ...current, + activeTabId: tabId, + snapshot, + desktopOverlay: current.desktopByTabId[tabId] ?? null, + }; + }); +} -export function selectThreadPreviewState( - byThreadKey: Record, - ref: ScopedThreadRef | null | undefined, -): ThreadPreviewState { - if (!ref) return EMPTY_THREAD_PREVIEW_STATE; - return ensureState(byThreadKey, scopedThreadKey(ref)); +export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { + if (url.trim().length === 0) return; + updateThreadPreviewState(ref, (current) => ({ + ...current, + recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), + })); +} + +export function removePreviewThread(ref: ScopedThreadRef): void { + const threadKey = scopedThreadKey(ref); + appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); + syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE); + changedPreviewThreadKeys.delete(threadKey); } export function isPreviewSupportedInRuntime(): boolean { @@ -290,6 +332,14 @@ export function isPreviewSupportedInRuntime(): boolean { return Boolean(window.desktopBridge?.preview); } +export function resetPreviewStateForTests(): void { + for (const threadKey of changedPreviewThreadKeys) { + appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); + } + changedPreviewThreadKeys.clear(); + appAtomRegistry.set(activePreviewThreadKeysAtom, { keys: new Set() }); +} + export const __testing = { EMPTY_THREAD_PREVIEW_STATE, RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT, diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index 1c02b4e8104..f0aeead1411 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -56,7 +56,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable) return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); } -export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { +export function primaryProjectScript(scripts: ReadonlyArray): ProjectScript | null { const regular = scripts.find((script) => !script.runOnWorktreeCreate); return regular ?? scripts[0] ?? null; } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index cf10aaefb74..a5b03f56328 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -1,7 +1,10 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + applyProviderInstanceSettings, deriveProviderInstanceEntries, + isProviderInstancePickerReady, + isProviderInstancePickerVisible, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, } from "./providerInstances"; @@ -30,6 +33,79 @@ function provider(input: { }; } +describe("isProviderInstancePickerReady", () => { + it("rejects a disabled instance even while its last probe status is ready", () => { + const [entry] = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + enabled: false, + }), + ]); + + expect(entry?.status).toBe("ready"); + expect(entry && isProviderInstancePickerReady(entry)).toBe(false); + }); + + it("accepts an enabled, available, ready instance", () => { + const [entry] = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + ]); + + expect(entry && isProviderInstancePickerReady(entry)).toBe(true); + }); +}); + +describe("isProviderInstancePickerVisible", () => { + it("keeps enabled instances in the rail and removes disabled instances", () => { + const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + enabled: false, + }), + ]); + + expect(enabledEntry && isProviderInstancePickerVisible(enabledEntry)).toBe(true); + expect(disabledEntry && isProviderInstancePickerVisible(disabledEntry)).toBe(false); + }); +}); + +describe("applyProviderInstanceSettings", () => { + it("uses settings when a streamed snapshot still reports a disabled default as enabled", () => { + const entries = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + ]); + const [entry] = applyProviderInstanceSettings(entries, { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, + }, + providers: {} as never, + }); + + expect(entry?.enabled).toBe(false); + }); + + it("treats a removed custom instance snapshot as disabled", () => { + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claude_work", + }), + ]); + const [entry] = applyProviderInstanceSettings(entries, { + providerInstances: {}, + providers: {} as never, + }); + + expect(entry?.enabled).toBe(false); + }); +}); + describe("deriveProviderInstanceEntries", () => { it("uses explicit instance id and driver kind from the snapshot", () => { const snapshot = provider({ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 3ec67c9e25e..c9ac87ac39f 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -19,6 +19,7 @@ import { type ProviderInstanceId, type ServerProvider, type ServerProviderModel, + type ServerSettings, type ServerProviderState, } from "@t3tools/contracts"; @@ -51,6 +52,21 @@ export interface ProviderInstanceEntry { readonly models: ReadonlyArray; } +/** + * Whether an instance can currently contribute models to an interactive picker. + * + * Disabling an instance updates `enabled` independently, while its previous + * `ready` probe status can remain in the streamed snapshot until reconciliation. + */ +export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean { + return entry.enabled && entry.isAvailable && entry.status === "ready"; +} + +/** Picker rails contain configured, enabled instances only. */ +export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean { + return entry.enabled; +} + /** * Turn an instance id slug into a human-readable label. Splits on `_` / `-` * and camelCase boundaries and title-cases each token, so `codex_personal` @@ -154,6 +170,35 @@ export function deriveProviderInstanceEntries( }); } +/** + * Overlay the current settings configuration onto streamed provider snapshots. + * Provider probes can briefly retain their previous `enabled` value after a + * settings write, so picker visibility must follow settings rather than waiting + * for probe reconciliation. + * + * Non-default instances only exist through `providerInstances`; if one is + * absent there, its streamed snapshot is stale (for example immediately after + * deletion) and is treated as disabled. + */ +export function applyProviderInstanceSettings( + entries: ReadonlyArray, + settings: Pick, +): ReadonlyArray { + const legacyProviders = settings.providers as Readonly< + Record + >; + + return entries.map((entry) => { + const explicitInstance = settings.providerInstances?.[entry.instanceId]; + const enabled = explicitInstance + ? (explicitInstance.enabled ?? true) + : entry.isDefault + ? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled) + : false; + return enabled === entry.enabled ? entry : { ...entry, enabled }; + }); +} + /** * Sort instance entries so the default instance of each driver kind appears * before any custom instances of the same kind. Within a kind, custom diff --git a/apps/web/src/providerUpdateDismissal.ts b/apps/web/src/providerUpdateDismissal.ts index 7cce819ccf9..28789152b34 100644 --- a/apps/web/src/providerUpdateDismissal.ts +++ b/apps/web/src/providerUpdateDismissal.ts @@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals { keys: [], } ); - } catch { + } catch (error) { + console.error("Could not read provider-update dismissals.", error); return { keys: [] }; } } @@ -33,8 +34,8 @@ function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void document, ProviderUpdateDismissalsSchema, ); - } catch { - // Dismissal state is best-effort UI state; a storage failure should not block the toast. + } catch (error) { + console.error("Could not persist provider-update dismissals.", error); } } diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 2995defc12f..b48f6e7ebb5 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; @@ -6,7 +6,6 @@ import { migratePersistedRightPanelState, selectActiveRightPanel, selectActiveRightPanelSurface, - selectActiveRightPanelKindWithUrl, selectThreadRightPanelState, useRightPanelStore, } from "./rightPanelStore"; @@ -254,16 +253,6 @@ describe("rightPanelStore", () => { expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); }); - it("?diff=1 always wins over persisted state", () => { - useRightPanelStore.getState().open(refA, "preview"); - expect( - selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, true), - ).toBe("diff"); - expect( - selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, false), - ).toBe("preview"); - }); - it("removeThread clears persisted state", () => { useRightPanelStore.getState().open(refA, "plan"); useRightPanelStore.getState().removeThread(refA); @@ -349,12 +338,12 @@ describe("rightPanelStore", () => { }); }); - it("closing the final terminal pane removes its surface but keeps the panel open", () => { + it("closing the final terminal pane removes its surface and closes the panel", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: true, + isOpen: false, activeSurfaceId: null, surfaces: [], }); @@ -370,12 +359,12 @@ describe("rightPanelStore", () => { ); }); - it("closing the final surface leaves the panel open and empty", () => { + it("closing the final surface closes the panel", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: true, + isOpen: false, activeSurfaceId: null, surfaces: [], }); @@ -417,14 +406,14 @@ describe("rightPanelStore", () => { }); }); - it("closing all surfaces leaves the panel open and empty", () => { + it("closing all surfaces closes the panel", () => { useRightPanelStore.getState().openBrowser(refA, "tab-a"); useRightPanelStore.getState().openFile(refA, "src/index.ts"); useRightPanelStore.getState().closeAllSurfaces(refA); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: true, + isOpen: false, activeSurfaceId: null, surfaces: [], }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 08f0c0cfd5f..70d163306cc 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -7,7 +7,7 @@ * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/plan/files remain singleton surfaces. */ -import { scopedThreadKey } from "@t3tools/client-runtime"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -340,6 +340,7 @@ export const useRightPanelStore = create()( const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; return { ...current, + isOpen: surfaces.length > 0 && current.isOpen, surfaces, activeSurfaceId: current.activeSurfaceId === surfaceId @@ -378,9 +379,16 @@ export const useRightPanelStore = create()( const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); - if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; + if (current.activeSurfaceId !== surfaceId) { + return { ...current, isOpen: surfaces.length > 0 && current.isOpen, surfaces }; + } const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; - return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null }; + return { + ...current, + isOpen: surfaces.length > 0 && current.isOpen, + surfaces, + activeSurfaceId: fallback?.id ?? null, + }; }), })), closeOtherSurfaces: (ref, surfaceId) => @@ -417,7 +425,7 @@ export const useRightPanelStore = create()( byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => current.surfaces.length === 0 ? current - : { ...current, isOpen: true, surfaces: [], activeSurfaceId: null }, + : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), })), reconcileBrowserSurfaces: (ref, tabIds) => @@ -550,13 +558,3 @@ export function selectActiveRightPanelSurface( if (!state.isOpen) return null; return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; } - -export function selectActiveRightPanelKindWithUrl( - byThreadKey: Record, - ref: ScopedThreadRef | null | undefined, - diffSearchActive: boolean, -): RightPanelKind | null { - if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null; - if (diffSearchActive) return "diff"; - return selectActiveRightPanel(byThreadKey, ref); -} diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 801a1433b4f..e6a911c1c0d 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,27 +1,14 @@ -import { createElement } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter, RouterHistory } from "@tanstack/react-router"; import type { NormalizedBasePath } from "@t3tools/shared/basePath"; -import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import { routeTree } from "./routeTree.gen"; export function getRouter(history: RouterHistory, basepath: NormalizedBasePath) { - const queryClient = new QueryClient(); - return createRouter({ routeTree, history, basepath, - context: { - queryClient, - }, - Wrap: ({ children }) => - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(AppAtomRegistryProvider, undefined, children), - ), + context: {}, }); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 88283d451c3..36de3b95706 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,26 +1,23 @@ import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { Outlet, - createRootRouteWithContext, + createRootRoute, type ErrorComponentProps, useLocation, useNavigate, } from "@tanstack/react-router"; -import { useEffect, useEffectEvent, useRef } from "react"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { APP_DISPLAY_NAME } from "../branding"; +import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; +import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; -import { - SlowRpcAckToastCoordinator, - WebSocketConnectionCoordinator, - WebSocketConnectionSurface, -} from "../components/WebSocketConnectionSurface"; +import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, @@ -29,44 +26,33 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { readLocalApi } from "../localApi"; -import { useSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, selectProjectGroupingSettings, } from "../logicalProject"; -import { - getServerConfigUpdatedNotification, - ServerConfigUpdatedNotification, - startServerStateSync, - useServerConfig, - useServerConfigUpdatedSubscription, - useServerWelcomeSubscription, -} from "../rpc/serverState"; -import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; -import { - ensureEnvironmentConnectionBootstrapped, - getPrimaryEnvironmentConnection, - listSavedEnvironmentRecords, - waitForSavedEnvironmentRegistryHydration, - startEnvironmentConnectionService, - useSavedEnvironmentRegistryStore, -} from "../environments/runtime"; import { configureClientTracing } from "../observability/clientTracing"; -import { - ensurePrimaryEnvironmentReady, - getPrimaryKnownEnvironment, - resolveInitialServerAuthGateState, - updatePrimaryEnvironmentDescriptor, -} from "../environments/primary"; +import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; +import { shellEnvironment } from "../state/shell"; +import { useAtomValue } from "@effect/atom-react"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { + primaryServerConfigAtom, + primaryServerConfigEventAtom, + primaryServerWelcomeAtom, +} from "../state/server"; +import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; +import { + createKeybindingsUpdateToastController, + type KeybindingsUpdateToastController, +} from "../components/KeybindingsUpdateToast.logic"; -export const Route = createRootRouteWithContext<{ - queryClient: QueryClient; -}>()({ +export const Route = createRootRoute({ beforeLoad: async ({ location }) => { if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { return { @@ -77,7 +63,6 @@ export const Route = createRootRouteWithContext<{ } if (isHostedStaticApp(new URL(window.location.href))) { - await waitForSavedEnvironmentRegistryHydration(); return { authGateState: { status: "hosted-static", @@ -85,10 +70,7 @@ export const Route = createRootRouteWithContext<{ }; } - const [, authGateState] = await Promise.all([ - ensurePrimaryEnvironmentReady(), - resolveInitialServerAuthGateState(), - ]); + const authGateState = await resolveInitialServerAuthGateState(); return { authGateState, }; @@ -115,11 +97,21 @@ function RootRouteView() { }, [pathname]); if (pathname === "/pair") { - return ; + return ( + <> + + + + ); } if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { - return ; + return ( + <> + + + + ); } const appShell = ( @@ -133,48 +125,61 @@ function RootRouteView() { return ( + {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? ( - {appShell} - ) : ( - appShell - )} + {appShell} ); } +function DocumentTitleSync() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + const title = resolveServerBackedAppDisplayName({ + baseName: APP_BASE_NAME, + fallbackDisplayName: APP_DISPLAY_NAME, + fallbackStageLabel: APP_STAGE_LABEL, + primaryServerVersion, + }); + + useEffect(() => { + document.title = title; + }, [title]); + + return null; +} + function HostedStaticEnvironmentBootstrap() { - const savedEnvironmentCount = useSavedEnvironmentRegistryStore( - (state) => Object.keys(state.byId).length, - ); + const { environments } = useEnvironments(); + const activeEnvironmentId = useActiveEnvironmentId(); useEffect(() => { - if (getPrimaryKnownEnvironment()) { + if ( + environments.some( + (environment) => environment.entry.target._tag === "PrimaryConnectionTarget", + ) + ) { return; } - const currentActiveEnvironmentId = useStore.getState().activeEnvironmentId; - if (currentActiveEnvironmentId) { + if (activeEnvironmentId) { return; } - const firstSavedEnvironment = listSavedEnvironmentRecords()[0]; + const firstSavedEnvironment = environments[0]; if (!firstSavedEnvironment) { return; } - useStore.getState().setActiveEnvironmentId(firstSavedEnvironment.environmentId); - }, [savedEnvironmentCount]); + setActiveEnvironmentId(firstSavedEnvironment.environmentId); + }, [activeEnvironmentId, environments]); return null; } @@ -250,18 +255,6 @@ function errorDetails(error: unknown): string { } } -function ServerStateBootstrap() { - useEffect(() => { - if (!getPrimaryKnownEnvironment()) { - return; - } - - return startServerStateSync(getPrimaryEnvironmentConnection().client.server); - }, []); - - return null; -} - function AuthenticatedTracingBootstrap() { useEffect(() => { void configureClientTracing(); @@ -270,46 +263,35 @@ function AuthenticatedTracingBootstrap() { return null; } -function EnvironmentConnectionManagerBootstrap() { - const queryClient = useQueryClient(); - - useEffect(() => { - return startEnvironmentConnectionService(queryClient); - }, [queryClient]); - - return null; -} - function EventRouter() { - const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const primaryEnvironment = usePrimaryEnvironment(); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom); + const serverWelcome = useAtomValue(primaryServerWelcomeAtom); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); - const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); - const lastKeybindingsSuccessToastAtRef = useRef(0); - const disposedRef = useRef(false); - const serverConfig = useServerConfig(); + const handledConfigEventRef = useRef(serverConfigEvent); + const [keybindingsToastController] = useState(() => + createKeybindingsUpdateToastController({}), + ); const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { if (!payload) return; - updatePrimaryEnvironmentDescriptor(payload.environment); setActiveEnvironmentId(payload.environment.environmentId); void (async () => { - await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); - if (disposedRef.current) { - return; - } - if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - const bootstrapEnvironmentState = - useStore.getState().environmentStateById[payload.environment.environmentId]; - const bootstrapProject = - bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; + const bootstrapProject = readProject( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), + ); const bootstrapProjectKey = (bootstrapProject ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) @@ -340,91 +322,84 @@ function EventRouter() { })().catch(() => undefined); }); - const handleServerConfigUpdated = useEffectEvent( - (notification: ServerConfigUpdatedNotification | null) => { - if (!notification) return; - - const { id, payload, source } = notification; - if (id <= seenServerConfigUpdateIdRef.current) { - return; - } - seenServerConfigUpdateIdRef.current = id; - if (source !== "keybindingsUpdated") { - return; - } + const handleServerConfigUpdated = useEffectEvent(() => { + const decision = keybindingsToastController.handle(serverConfigEvent); + if (!decision) { + return; + } - const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); - if (!issue) { - const now = Date.now(); - if (now - lastKeybindingsSuccessToastAtRef.current < 2_000) { - return; - } - lastKeybindingsSuccessToastAtRef.current = now; - toastManager.add({ - type: "success", - title: "Keybindings updated", - description: "Keybindings configuration reloaded successfully.", - }); - return; - } + if (decision._tag === "Success") { + toastManager.add({ + type: "success", + title: "Keybindings updated", + description: "Keybindings configuration reloaded successfully.", + }); + return; + } - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Invalid keybindings configuration", - description: issue.message, - actionVariant: "outline", - actionProps: { - children: "Open keybindings.json", - onClick: () => { - const api = readLocalApi(); - if (!api) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Invalid keybindings configuration", + description: decision.message, + actionVariant: "outline", + actionProps: { + children: "Open keybindings.json", + onClick: () => { + if (!serverConfig || !primaryEnvironment) { + return; + } + + const editor = resolveAndPersistPreferredEditor(serverConfig.availableEditors); + if (!editor) { + return; + } + void (async () => { + const result = await openInEditor({ + environmentId: primaryEnvironment.environmentId, + input: { + cwd: serverConfig.keybindingsConfigPath, + editor, + }, + }); + if (result._tag === "Success") { return; } - - void Promise.resolve(serverConfig ?? api.server.getConfig()) - .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); - if (!editor) { - throw new Error("No available editors found."); - } - return api.shell.openInEditor(config.keybindingsConfigPath, editor); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open keybindings file", - description: - error instanceof Error ? error.message : "Unknown error opening file.", - }), - ); - }); - }, + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open keybindings file", + description: + error instanceof Error ? error.message : "Unknown error opening file.", + }), + ); + })(); }, - }), - ); - }, - ); + }, + }), + ); + }); useEffect(() => { if (!serverConfig) { return; } - updatePrimaryEnvironmentDescriptor(serverConfig.environment); setActiveEnvironmentId(serverConfig.environment.environmentId); - }, [serverConfig, setActiveEnvironmentId]); + }, [serverConfig]); useEffect(() => { - disposedRef.current = false; - return () => { - disposedRef.current = true; - }; - }, []); + handleWelcome(serverWelcome); + }, [serverWelcome]); - useServerWelcomeSubscription(handleWelcome); - useServerConfigUpdatedSubscription(handleServerConfigUpdated); + useEffect(() => { + if (serverConfigEvent === null || handledConfigEventRef.current === serverConfigEvent) { + return; + } + handledConfigEventRef.current = serverConfigEvent; + handleServerConfigUpdated(); + }, [serverConfigEvent]); return null; } diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 13b6f9a5d59..7dc6702b4ec 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,28 +1,29 @@ -import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; -import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef } from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; +import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { environmentShell } from "../state/shell"; function ChatThreadRouteView() { const navigate = useNavigate(); const threadRef = Route.useParams({ select: (params) => resolveThreadRouteRef(params), }); - const bootstrapComplete = useStore( - (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, - ); - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); - const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef)); - const environmentHasServerThreads = useStore( - (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, + const shell = useEnvironmentQuery( + threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), ); + const serverThreadShell = useThreadShell(threadRef); + const serverThreadDetail = useThreadDetail(threadRef); + const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); + const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const threadExists = serverThreadShell !== null || serverThreadDetail !== null; + const environmentHasServerThreads = environmentThreadRefs.length > 0; const draftThreadExists = useComposerDraftStore((store) => threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, ); @@ -30,26 +31,35 @@ function ChatThreadRouteView() { threadRef ? store.getDraftThreadByRef(threadRef) : null, ); const environmentHasDraftThreads = useComposerDraftStore((store) => { - if (!threadRef) return false; + if (!threadRef) { + return false; + } return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); const routeThreadExists = threadExists || draftThreadExists; - const serverThreadStarted = threadHasStarted(serverThread); + const serverThreadStarted = threadHasStarted(serverThreadDetail); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; useEffect(() => { - if (!threadRef || !bootstrapComplete) return; + if (!threadRef || !bootstrapComplete) { + return; + } + if (!routeThreadExists && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); } }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); useEffect(() => { - if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) return; + if (!threadRef || !serverThreadStarted || !draftThread) { + return; + } finalizePromotedDraftThreadByRef(threadRef); - }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); + }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || !bootstrapComplete || !routeThreadExists) return null; + if (!threadRef || !bootstrapComplete || !routeThreadExists) { + return null; + } return ( @@ -63,9 +73,5 @@ function ChatThreadRouteView() { } export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ - validateSearch: (search) => parseDiffRouteSearch(search), - search: { - middlewares: [retainSearchParams(["diff"])], - }, component: ChatThreadRouteView, }); diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 77b9f18f0d7..dd152ce1a9d 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,39 +1,40 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; -import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { + DraftId, + markPromotedDraftThreadByRef, + useComposerDraftStore, +} from "../composerDraftStore"; import { SidebarInset } from "../components/ui/sidebar"; -import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; -import { useStore } from "../store"; import { buildThreadRouteParams } from "../threadRoutes"; +import { useThread, useThreadRefs } from "../state/entities"; function DraftChatThreadRouteView() { const navigate = useNavigate(); const { draftId: rawDraftId } = Route.useParams(); const draftId = DraftId.make(rawDraftId); const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); - const serverThread = useStore( - useMemo( - () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null), - [draftSession?.threadId], - ), - ); + const threadRefs = useThreadRefs(); + const inferredThreadRef = draftSession + ? (threadRefs.find( + (ref) => + ref.environmentId === draftSession.environmentId && + ref.threadId === draftSession.threadId, + ) ?? null) + : null; + const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; + const serverThread = useThread(serverThreadRef); const serverThreadStarted = threadHasStarted(serverThread); - const canonicalThreadRef = useMemo( - () => - draftSession?.promotedTo - ? serverThreadStarted - ? draftSession.promotedTo - : null - : serverThread - ? { - environmentId: serverThread.environmentId, - threadId: serverThread.id, - } - : null, - [draftSession?.promotedTo, serverThread, serverThreadStarted], - ); + const canonicalThreadRef = serverThreadStarted ? serverThreadRef : null; + + useEffect(() => { + if (!inferredThreadRef || draftSession?.promotedTo) { + return; + } + markPromotedDraftThreadByRef(inferredThreadRef); + }, [draftSession?.promotedTo, inferredThreadRef]); useEffect(() => { if (!canonicalThreadRef) { diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 896f66b3e93..94d49d00afe 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,18 +4,18 @@ import { LinkIcon, PlusIcon } from "lucide-react"; import { NoActiveThreadState } from "../components/NoActiveThreadState"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; -import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; +import { SidebarInset } from "../components/ui/sidebar"; +import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); - const savedEnvironmentCount = useSavedEnvironmentRegistryStore( - (state) => Object.keys(state.byId).length, - ); + const { environments } = useEnvironments(); - if (authGateState.status === "hosted-static" && savedEnvironmentCount === 0) { + if (authGateState.status === "hosted-static" && environments.length === 0) { return ; } @@ -32,9 +32,13 @@ function HostedStaticOnboardingState() { return (
    -
    +
    - {APP_DISPLAY_NAME} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index f28c80f0128..9fb1eae721e 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,7 +1,8 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; -import { useCommandPaletteStore } from "../commandPaletteStore"; +import { isCommandPaletteOpen } from "../commandPaletteContext"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -15,17 +16,15 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { useSettings } from "~/hooks/useSettings"; -import { useServerKeybindings } from "~/rpc/serverState"; +import { primaryServerKeybindingsAtom } from "~/state/server"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); - const keybindings = useServerKeybindings(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -39,8 +38,6 @@ function ChatRouteGlobalShortcuts() { ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" : false, ); - const appSettings = useSettings(); - useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; @@ -53,7 +50,7 @@ function ChatRouteGlobalShortcuts() { }, }); - if (useCommandPaletteStore.getState().open) { + if (isCommandPaletteOpen()) { return; } @@ -68,11 +65,8 @@ function ChatRouteGlobalShortcuts() { event.stopPropagation(); void startNewLocalThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), handleNewThread, }); return; @@ -83,11 +77,8 @@ function ChatRouteGlobalShortcuts() { event.stopPropagation(); void startNewThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), handleNewThread, }); return; @@ -152,7 +143,6 @@ function ChatRouteGlobalShortcuts() { routeThreadRef, selectedThreadKeysSize, terminalOpen, - appSettings.defaultThreadEnvMode, ]); return null; diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index fa5c6a4201d..40507321066 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -11,8 +11,10 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -64,9 +66,13 @@ function SettingsContentLayout() {
    {!isElectron && ( -
    +
    - Settings {showRestoreDefaults ? (
    @@ -78,7 +84,12 @@ function SettingsContentLayout() { )} {isElectron && ( -
    +
    Settings diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts deleted file mode 100644 index 950ab21f57d..00000000000 --- a/apps/web/src/rpc/serverState.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - ProjectId, - ThreadId, - type ServerConfig, - type ServerConfigStreamEvent, - type ServerLifecycleStreamEvent, - type ServerProvider, -} from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - getServerConfig, - getServerKeybindings, - onProvidersUpdated, - onServerConfigUpdated, - onWelcome, - resetServerStateForTests, - startServerStateSync, -} from "./serverState"; - -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -function createDeferredPromise() { - let resolve!: (value: T) => void; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - - return { promise, resolve }; -} - -const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>(); -const configListeners = new Set<(event: ServerConfigStreamEvent) => void>(); - -const defaultProviders: ReadonlyArray = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }, -]; - -const baseEnvironment = { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { - os: "darwin" as const, - arch: "arm64" as const, - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, -}; - -const baseServerConfig: ServerConfig = { - environment: baseEnvironment, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/tmp/workspace", - keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", - keybindings: [], - issues: [], - providers: defaultProviders, - availableEditors: ["cursor"], - observability: { - logsDirectoryPath: "/tmp/workspace/.config/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: DEFAULT_SERVER_SETTINGS, -}; - -const serverApi = { - getConfig: vi.fn<() => Promise>(), - subscribeConfig: vi.fn((listener: (event: ServerConfigStreamEvent) => void) => - registerListener(configListeners, listener), - ), - subscribeLifecycle: vi.fn((listener: (event: ServerLifecycleStreamEvent) => void) => - registerListener(lifecycleListeners, listener), - ), -}; - -function emitLifecycleEvent(event: ServerLifecycleStreamEvent) { - for (const listener of lifecycleListeners) { - listener(event); - } -} - -function emitServerConfigEvent(event: ServerConfigStreamEvent) { - for (const listener of configListeners) { - listener(event); - } -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = Date.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (Date.now() - startedAt >= timeoutMs) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -beforeEach(() => { - vi.clearAllMocks(); - lifecycleListeners.clear(); - configListeners.clear(); - resetServerStateForTests(); -}); - -afterEach(() => { - resetServerStateForTests(); -}); - -describe("serverState", () => { - it("uses default keybindings before a server config snapshot is available", () => { - expect(getServerConfig()).toBeNull(); - expect(getServerKeybindings()).toEqual(DEFAULT_RESOLVED_KEYBINDINGS); - }); - - it("bootstraps the server config snapshot and replays it to late subscribers", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - - const configListener = vi.fn(); - const stop = startServerStateSync(serverApi); - const unsubscribe = onServerConfigUpdated(configListener); - - await waitFor(() => { - expect(getServerConfig()).toEqual(baseServerConfig); - }); - - expect(serverApi.subscribeConfig).toHaveBeenCalledOnce(); - expect(serverApi.subscribeLifecycle).toHaveBeenCalledOnce(); - expect(serverApi.getConfig).toHaveBeenCalledOnce(); - expect(configListener).toHaveBeenCalledWith( - { - issues: [], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "snapshot", - ); - - const lateListener = vi.fn(); - const unsubscribeLate = onServerConfigUpdated(lateListener); - expect(lateListener).toHaveBeenCalledWith( - { - issues: [], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "snapshot", - ); - - unsubscribeLate(); - unsubscribe(); - stop(); - }); - - it("keeps the streamed snapshot when it arrives before the fallback fetch resolves", async () => { - const deferred = createDeferredPromise(); - serverApi.getConfig.mockReturnValueOnce(deferred.promise); - const stop = startServerStateSync(serverApi); - - const streamedConfig: ServerConfig = { - ...baseServerConfig, - cwd: "/tmp/from-stream", - }; - - emitServerConfigEvent({ - version: 1, - type: "snapshot", - config: streamedConfig, - }); - - await waitFor(() => { - expect(getServerConfig()).toEqual(streamedConfig); - }); - - deferred.resolve(baseServerConfig); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(getServerConfig()).toEqual(streamedConfig); - stop(); - }); - - it("replays welcome events to late subscribers", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - const stop = startServerStateSync(serverApi); - - const listener = vi.fn(); - const unsubscribe = onWelcome(listener); - - emitLifecycleEvent({ - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }, - }); - - expect(listener).toHaveBeenCalledWith({ - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }); - - const lateListener = vi.fn(); - const unsubscribeLate = onWelcome(lateListener); - expect(lateListener).toHaveBeenCalledWith({ - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }); - - unsubscribeLate(); - unsubscribe(); - stop(); - }); - - it("merges provider, settings, and keybinding updates into the cached config", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - const configListener = vi.fn(); - const providersListener = vi.fn(); - const stop = startServerStateSync(serverApi); - const unsubscribeConfig = onServerConfigUpdated(configListener); - const unsubscribeProviders = onProvidersUpdated(providersListener); - - await waitFor(() => { - expect(getServerConfig()).toEqual(baseServerConfig); - }); - - const nextProviders: ReadonlyArray = [ - { - ...defaultProviders[0]!, - status: "warning", - checkedAt: "2026-01-02T00:00:00.000Z", - message: "rate limited", - }, - ]; - - const nextKeybindings = [ - { - command: "commandPalette.toggle", - shortcut: { - key: "p", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - }, - ] as const; - - emitServerConfigEvent({ - version: 1, - type: "keybindingsUpdated", - payload: { - keybindings: nextKeybindings, - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - }, - }); - emitServerConfigEvent({ - version: 1, - type: "providerStatuses", - payload: { - providers: nextProviders, - }, - }); - emitServerConfigEvent({ - version: 1, - type: "settingsUpdated", - payload: { - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }, - }); - - await waitFor(() => { - expect(getServerConfig()).toEqual({ - ...baseServerConfig, - keybindings: nextKeybindings, - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }); - }); - - expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders }); - expect(configListener).toHaveBeenNthCalledWith( - 2, - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "keybindingsUpdated", - ); - expect(configListener).toHaveBeenNthCalledWith( - 3, - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "providerStatuses", - ); - expect(configListener).toHaveBeenLastCalledWith( - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }, - "settingsUpdated", - ); - - unsubscribeProviders(); - unsubscribeConfig(); - stop(); - }); -}); diff --git a/apps/web/src/rpc/serverState.ts b/apps/web/src/rpc/serverState.ts deleted file mode 100644 index 64bc2d80e5a..00000000000 --- a/apps/web/src/rpc/serverState.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { useAtomSubscribe, useAtomValue } from "@effect/atom-react"; -import { - DEFAULT_SERVER_SETTINGS, - type EditorId, - type ServerConfig, - type ServerConfigStreamEvent, - type ServerConfigUpdatedPayload, - type ServerLifecycleWelcomePayload, - type ServerProvider, - type ServerProviderUpdatedPayload, - type ServerSettings, -} from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { Atom } from "effect/unstable/reactivity"; -import { useCallback, useRef } from "react"; - -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; - -export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; - -export interface ServerConfigUpdatedNotification { - readonly id: number; - readonly payload: ServerConfigUpdatedPayload; - readonly source: ServerConfigUpdateSource; -} - -type ServerStateClient = Pick< - WsRpcClient["server"], - "getConfig" | "subscribeConfig" | "subscribeLifecycle" ->; - -function makeStateAtom(label: string, initialValue: A) { - return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); -} - -function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload { - return { - issues: config.issues, - providers: config.providers, - settings: config.settings, - }; -} - -const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; - -const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray => - config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; -const selectKeybindings = (config: ServerConfig | null) => - config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; -const selectKeybindingsConfigPath = (config: ServerConfig | null) => - config?.keybindingsConfigPath ?? null; -const selectObservability = (config: ServerConfig | null) => config?.observability ?? null; -const selectProviders = (config: ServerConfig | null) => - config?.providers ?? EMPTY_SERVER_PROVIDERS; -const selectSettings = (config: ServerConfig | null): ServerSettings => - config?.settings ?? DEFAULT_SERVER_SETTINGS; - -export const welcomeAtom = makeStateAtom( - "server-welcome", - null, -); -export const serverConfigAtom = makeStateAtom("server-config", null); -export const serverConfigUpdatedAtom = makeStateAtom( - "server-config-updated", - null, -); -export const providersUpdatedAtom = makeStateAtom( - "server-providers-updated", - null, -); - -export function getServerConfig(): ServerConfig | null { - return appAtomRegistry.get(serverConfigAtom); -} - -export function getServerKeybindings(): ServerConfig["keybindings"] { - return selectKeybindings(getServerConfig()); -} - -export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null { - return appAtomRegistry.get(serverConfigUpdatedAtom); -} - -export function setServerConfigSnapshot(config: ServerConfig): void { - resolveServerConfig(config); - emitProvidersUpdated({ providers: config.providers }); - emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); -} - -export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { - switch (event.type) { - case "snapshot": { - setServerConfigSnapshot(event.config); - return; - } - case "keybindingsUpdated": { - const latestServerConfig = getServerConfig(); - if (!latestServerConfig) { - return; - } - const nextConfig = { - ...latestServerConfig, - keybindings: event.payload.keybindings, - issues: event.payload.issues, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); - return; - } - case "providerStatuses": { - applyProvidersUpdated(event.payload); - return; - } - case "settingsUpdated": { - applySettingsUpdated(event.payload.settings); - return; - } - } -} - -export function applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { - const latestServerConfig = getServerConfig(); - emitProvidersUpdated(payload); - - if (!latestServerConfig) { - return; - } - - const nextConfig = { - ...latestServerConfig, - providers: payload.providers, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); -} - -export function applySettingsUpdated(settings: ServerSettings): void { - const latestServerConfig = getServerConfig(); - if (!latestServerConfig) { - return; - } - - const nextConfig = { - ...latestServerConfig, - settings, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); -} - -export function emitWelcome(payload: ServerLifecycleWelcomePayload): void { - appAtomRegistry.set(welcomeAtom, payload); -} - -export function onWelcome(listener: (payload: ServerLifecycleWelcomePayload) => void): () => void { - return subscribeLatest(welcomeAtom, listener); -} - -export function onServerConfigUpdated( - listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, -): () => void { - return subscribeLatest(serverConfigUpdatedAtom, (notification) => { - listener(notification.payload, notification.source); - }); -} - -export function onProvidersUpdated( - listener: (payload: ServerProviderUpdatedPayload) => void, -): () => void { - return subscribeLatest(providersUpdatedAtom, listener); -} - -export function startServerStateSync(client: ServerStateClient): () => void { - let disposed = false; - const cleanups = [ - client.subscribeLifecycle((event) => { - if (event.type === "welcome") { - emitWelcome(event.payload); - } - }), - client.subscribeConfig((event) => { - applyServerConfigEvent(event); - }), - ]; - - if (getServerConfig() === null) { - void client - .getConfig() - .then((config) => { - if (disposed || getServerConfig() !== null) { - return; - } - setServerConfigSnapshot(config); - }) - .catch(() => undefined); - } - - return () => { - disposed = true; - for (const cleanup of cleanups) { - cleanup(); - } - }; -} - -export function resetServerStateForTests() { - resetAppAtomRegistryForTests(); - nextServerConfigUpdatedNotificationId = 1; -} - -let nextServerConfigUpdatedNotificationId = 1; - -function resolveServerConfig(config: ServerConfig): void { - appAtomRegistry.set(serverConfigAtom, config); -} - -function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { - appAtomRegistry.set(providersUpdatedAtom, payload); -} - -function emitServerConfigUpdated( - payload: ServerConfigUpdatedPayload, - source: ServerConfigUpdateSource, -): void { - appAtomRegistry.set(serverConfigUpdatedAtom, { - id: nextServerConfigUpdatedNotificationId++, - payload, - source, - }); -} - -function subscribeLatest( - atom: Atom.Atom, - listener: (value: NonNullable) => void, -): () => void { - return appAtomRegistry.subscribe( - atom, - (value) => { - if (value === null) { - return; - } - listener(value as NonNullable); - }, - { immediate: true }, - ); -} - -function useLatestAtomSubscription( - atom: Atom.Atom, - listener: (value: NonNullable) => void, -): void { - const listenerRef = useRef(listener); - listenerRef.current = listener; - - const stableListener = useCallback((value: A | null) => { - if (value === null) { - return; - } - listenerRef.current(value as NonNullable); - }, []); - - useAtomSubscribe(atom, stableListener, { immediate: true }); -} - -export function useServerConfig(): ServerConfig | null { - return useAtomValue(serverConfigAtom); -} - -export function useServerSettings(): ServerSettings { - return useAtomValue(serverConfigAtom, selectSettings); -} - -export function useServerProviders(): ReadonlyArray { - return useAtomValue(serverConfigAtom, selectProviders); -} - -export function useServerKeybindings(): ServerConfig["keybindings"] { - return useAtomValue(serverConfigAtom, selectKeybindings); -} - -export function useServerAvailableEditors(): ReadonlyArray { - return useAtomValue(serverConfigAtom, selectAvailableEditors); -} - -export function useServerKeybindingsConfigPath(): string | null { - return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); -} - -export function useServerObservability(): ServerConfig["observability"] | null { - return useAtomValue(serverConfigAtom, selectObservability); -} - -export function useServerWelcomeSubscription( - listener: (payload: ServerLifecycleWelcomePayload) => void, -): void { - useLatestAtomSubscription(welcomeAtom, listener); -} - -export function useServerConfigUpdatedSubscription( - listener: (notification: ServerConfigUpdatedNotification) => void, -): void { - useLatestAtomSubscription(serverConfigUpdatedAtom, listener); -} diff --git a/apps/web/src/rpc/transportError.ts b/apps/web/src/rpc/transportError.ts index 649d06f3a70..493de5f93bd 100644 --- a/apps/web/src/rpc/transportError.ts +++ b/apps/web/src/rpc/transportError.ts @@ -1,4 +1,4 @@ export { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/errors"; diff --git a/apps/web/src/rpc/wsConnectionState.test.ts b/apps/web/src/rpc/wsConnectionState.test.ts deleted file mode 100644 index efb4d6e62f3..00000000000 --- a/apps/web/src/rpc/wsConnectionState.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - getWsConnectionStatus, - getWsReconnectDelayMsForRetry, - getWsConnectionUiState, - recordWsConnectionAttempt, - recordWsConnectionClosed, - recordWsConnectionErrored, - recordWsConnectionOpened, - resetWsConnectionStateForTests, - setBrowserOnlineStatus, - WS_RECONNECT_MAX_ATTEMPTS, -} from "./wsConnectionState"; - -describe("wsConnectionState", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-03T20:30:00.000Z")); - resetWsConnectionStateForTests(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("treats a disconnected browser as offline once the websocket drops", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionOpened(); - recordWsConnectionClosed({ code: 1006, reason: "offline" }); - setBrowserOnlineStatus(false); - - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("offline"); - }); - - it("stays in the initial connecting state until the first disconnect", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 1, - hasConnected: false, - phase: "connecting", - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("connecting"); - }); - - it("schedules the next retry after a failed websocket attempt", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws", { - connectionLabel: "Remote Mac", - }); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); - - const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0); - if (firstRetryDelayMs === null) { - throw new Error("Expected an initial retry delay."); - } - - expect(getWsConnectionStatus()).toMatchObject({ - connectionLabel: "Remote Mac", - nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(), - reconnectAttemptCount: 1, - reconnectPhase: "waiting", - }); - }); - - it("adds a version mismatch hint to websocket errors when metadata includes one", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws", { - connectionLabel: "Remote Mac", - }); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.", { - versionMismatchHint: "Version mismatch. Try syncing the client and server.", - }); - - expect(getWsConnectionStatus()).toMatchObject({ - lastError: - "Unable to connect to the T3 server WebSocket. Hint: Version mismatch. Try syncing the client and server.", - }); - }); - - it("adds a version mismatch hint to websocket close reasons when metadata includes one", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionOpened(); - recordWsConnectionClosed( - { code: 1006, reason: "socket closed" }, - { - versionMismatchHint: "Version mismatch. Try syncing the client and server.", - }, - ); - - expect(getWsConnectionStatus()).toMatchObject({ - closeReason: "socket closed Hint: Version mismatch. Try syncing the client and server.", - }); - }); - - it("marks the reconnect cycle as exhausted after the final attempt fails", () => { - for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); - } - - expect(getWsConnectionStatus()).toMatchObject({ - nextRetryAt: null, - reconnectAttemptCount: WS_RECONNECT_MAX_ATTEMPTS, - reconnectPhase: "exhausted", - }); - }); -}); diff --git a/apps/web/src/rpc/wsConnectionState.ts b/apps/web/src/rpc/wsConnectionState.ts deleted file mode 100644 index 9e67f461184..00000000000 --- a/apps/web/src/rpc/wsConnectionState.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { DEFAULT_RECONNECT_BACKOFF, getReconnectDelayMs } from "@t3tools/client-runtime"; -import { Atom } from "effect/unstable/reactivity"; - -import { appAtomRegistry } from "./atomRegistry"; - -export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting"; -export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting"; - -export const WS_RECONNECT_INITIAL_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.initialDelayMs; -export const WS_RECONNECT_BACKOFF_FACTOR = DEFAULT_RECONNECT_BACKOFF.backoffFactor; -export const WS_RECONNECT_MAX_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.maxDelayMs; -export const WS_RECONNECT_MAX_RETRIES = DEFAULT_RECONNECT_BACKOFF.maxRetries!; -export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1; - -export interface WsConnectionStatus { - readonly attemptCount: number; - readonly closeCode: number | null; - readonly closeReason: string | null; - readonly connectionLabel: string | null; - readonly connectedAt: string | null; - readonly disconnectedAt: string | null; - readonly hasConnected: boolean; - readonly lastError: string | null; - readonly lastErrorAt: string | null; - readonly nextRetryAt: string | null; - readonly online: boolean; - readonly phase: "idle" | "connecting" | "connected" | "disconnected"; - readonly reconnectAttemptCount: number; - readonly reconnectMaxAttempts: number; - readonly reconnectPhase: WsReconnectPhase; - readonly socketUrl: string | null; -} - -const INITIAL_WS_CONNECTION_STATUS = Object.freeze({ - attemptCount: 0, - closeCode: null, - closeReason: null, - connectionLabel: null, - connectedAt: null, - disconnectedAt: null, - hasConnected: false, - lastError: null, - lastErrorAt: null, - nextRetryAt: null, - online: typeof navigator === "undefined" ? true : navigator.onLine !== false, - phase: "idle", - reconnectAttemptCount: 0, - reconnectMaxAttempts: WS_RECONNECT_MAX_ATTEMPTS, - reconnectPhase: "idle", - socketUrl: null, -}); - -export const wsConnectionStatusAtom = Atom.make(INITIAL_WS_CONNECTION_STATUS).pipe( - Atom.keepAlive, - Atom.withLabel("ws-connection-status"), -); - -function isoNow() { - return new Date().toISOString(); -} - -function updateWsConnectionStatus( - updater: (current: WsConnectionStatus) => WsConnectionStatus, -): WsConnectionStatus { - const nextStatus = updater(getWsConnectionStatus()); - appAtomRegistry.set(wsConnectionStatusAtom, nextStatus); - return nextStatus; -} - -export interface WsConnectionMetadata { - readonly connectionLabel?: string | null; - readonly versionMismatchHint?: string | null; -} - -function normalizeConnectionLabel(label: string | null | undefined): string | null { - const normalized = label?.trim(); - return normalized ? normalized : null; -} - -export function getWsConnectionStatus(): WsConnectionStatus { - return appAtomRegistry.get(wsConnectionStatusAtom); -} - -export function getWsConnectionUiState(status: WsConnectionStatus): WsConnectionUiState { - if (status.phase === "connected") { - return "connected"; - } - - if (!status.online && (status.disconnectedAt !== null || status.phase === "disconnected")) { - return "offline"; - } - - if (!status.hasConnected) { - return status.phase === "disconnected" ? "error" : "connecting"; - } - - return "reconnecting"; -} - -export function recordWsConnectionAttempt( - socketUrl: string, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => ({ - ...current, - attemptCount: current.attemptCount + 1, - connectionLabel: connectionLabel ?? current.connectionLabel, - nextRetryAt: null, - phase: "connecting", - reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1, - reconnectPhase: "attempting", - socketUrl, - })); -} - -export function recordWsConnectionOpened(metadata?: WsConnectionMetadata): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => ({ - ...current, - closeCode: null, - closeReason: null, - connectionLabel: connectionLabel ?? current.connectionLabel, - connectedAt: isoNow(), - disconnectedAt: null, - hasConnected: true, - nextRetryAt: null, - phase: "connected", - reconnectAttemptCount: 0, - reconnectPhase: "idle", - })); -} - -function appendHint(message: string | null | undefined, hint: string | null | undefined) { - const normalizedMessage = message?.trim(); - const normalizedHint = hint?.trim(); - if (!normalizedMessage) { - return normalizedHint ? `Hint: ${normalizedHint}` : null; - } - return normalizedHint ? `${normalizedMessage} Hint: ${normalizedHint}` : normalizedMessage; -} - -export function recordWsConnectionErrored( - message?: string | null, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - return updateWsConnectionStatus((current) => - applyDisconnectState(current, { - lastError: - appendHint(message, metadata?.versionMismatchHint) ?? - appendHint(current.lastError, metadata?.versionMismatchHint), - lastErrorAt: isoNow(), - }), - ); -} - -export function recordWsConnectionClosed( - details?: { - readonly code?: number; - readonly reason?: string; - }, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => - applyDisconnectState( - current, - { - closeCode: details?.code ?? current.closeCode, - closeReason: - appendHint(details?.reason, metadata?.versionMismatchHint) ?? - appendHint(current.closeReason, metadata?.versionMismatchHint), - }, - connectionLabel === null ? undefined : { connectionLabel }, - ), - ); -} - -export function setBrowserOnlineStatus(online: boolean): WsConnectionStatus { - return updateWsConnectionStatus((current) => ({ - ...current, - online, - })); -} - -export function resetWsReconnectBackoff(): WsConnectionStatus { - return updateWsConnectionStatus((current) => ({ - ...current, - nextRetryAt: null, - reconnectAttemptCount: 0, - reconnectPhase: "idle", - })); -} - -export function resetWsConnectionStateForTests(): void { - appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS); -} - -export function useWsConnectionStatus(): WsConnectionStatus { - return useAtomValue(wsConnectionStatusAtom); -} - -export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null { - return getReconnectDelayMs(retryIndex); -} - -function applyDisconnectState( - current: WsConnectionStatus, - updates: Partial< - Pick - >, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const disconnectedAt = current.disconnectedAt ?? isoNow(); - const nextRetryDelayMs = - current.nextRetryAt !== null || current.reconnectPhase === "exhausted" - ? null - : getWsReconnectDelayMsForRetry(Math.max(0, current.reconnectAttemptCount - 1)); - - return { - ...current, - ...updates, - connectionLabel: normalizeConnectionLabel(metadata?.connectionLabel) ?? current.connectionLabel, - disconnectedAt, - nextRetryAt: - nextRetryDelayMs === null - ? current.nextRetryAt - : new Date(Date.now() + nextRetryDelayMs).toISOString(), - phase: "disconnected", - reconnectPhase: - current.reconnectPhase === "waiting" || current.reconnectPhase === "exhausted" - ? current.reconnectPhase - : nextRetryDelayMs === null - ? "exhausted" - : "waiting", - }; -} diff --git a/apps/web/src/rpc/wsTransport.test.ts b/apps/web/src/rpc/wsTransport.test.ts deleted file mode 100644 index eb6fb494da2..00000000000 --- a/apps/web/src/rpc/wsTransport.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - __resetClientTracingForTests, - configureClientTracing, -} from "../observability/clientTracing"; -import { - getSlowRpcAckRequests, - resetRequestLatencyStateForTests, - setSlowRpcAckThresholdMsForTests, -} from "../rpc/requestLatencyState"; -import { - getWsConnectionStatus, - getWsConnectionUiState, - resetWsConnectionStateForTests, -} from "../rpc/wsConnectionState"; -import { WsTransport } from "./wsTransport"; - -const encodeServerSettings = Schema.encodeSync(ServerSettings); - -type WsEventType = "open" | "message" | "close" | "error"; -type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; -type WsListener = (event?: WsEvent) => void; - -const sockets: MockWebSocket[] = []; - -class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readyState = MockWebSocket.CONNECTING; - readonly sent: string[] = []; - readonly url: string; - private readonly listeners = new Map>(); - - constructor(url: string) { - this.url = url; - sockets.push(this); - } - - addEventListener(type: WsEventType, listener: WsListener) { - const listeners = this.listeners.get(type) ?? new Set(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - - removeEventListener(type: WsEventType, listener: WsListener) { - this.listeners.get(type)?.delete(listener); - } - - send(data: string) { - this.sent.push(data); - } - - close(code = 1000, reason = "") { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", { code, reason, type: "close" }); - } - - open() { - this.readyState = MockWebSocket.OPEN; - this.emit("open", { type: "open" }); - } - - serverMessage(data: unknown) { - this.emit("message", { data, type: "message" }); - } - - error() { - this.emit("error", { type: "error" }); - } - - private emit(type: WsEventType, event?: WsEvent) { - const listeners = this.listeners.get(type); - if (!listeners) return; - for (const listener of listeners) { - listener(event); - } - } -} - -const originalWebSocket = globalThis.WebSocket; -const originalFetch = globalThis.fetch; -const transports: WsTransport[] = []; - -function getSocket(): MockWebSocket { - const socket = sockets.at(-1); - if (!socket) { - throw new Error("Expected a websocket instance"); - } - return socket; -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = Date.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (Date.now() - startedAt >= timeoutMs) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -function createTransport(...args: ConstructorParameters): WsTransport { - const transport = new WsTransport(...args); - transports.push(transport); - return transport; -} - -beforeEach(() => { - vi.useRealTimers(); - sockets.length = 0; - transports.length = 0; - resetRequestLatencyStateForTests(); - resetWsConnectionStateForTests(); - - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - location: { - origin: "http://localhost:3020", - hostname: "localhost", - port: "3020", - protocol: "http:", - }, - desktopBridge: undefined, - }, - }); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { onLine: true }, - }); - - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; -}); - -afterEach(async () => { - await Promise.allSettled(transports.map((transport) => transport.dispose())); - transports.length = 0; - globalThis.WebSocket = originalWebSocket; - globalThis.fetch = originalFetch; - resetRequestLatencyStateForTests(); - resetWsConnectionStateForTests(); - await __resetClientTracingForTests(); - vi.restoreAllMocks(); -}); - -describe("WsTransport (web instrumentation)", () => { - it("tracks initial connection failures for the app error state", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 1, - phase: "connecting", - socketUrl: "ws://localhost:3020/ws", - }); - - socket.error(); - socket.close(1006, "server unavailable"); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - closeCode: 1006, - closeReason: "server unavailable", - hasConnected: false, - lastError: "Unable to connect to the T3 server WebSocket.", - phase: "disconnected", - }); - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("error"); - - await transport.dispose(); - }); - - it("surfaces reconnecting state after a live socket disconnects", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - hasConnected: true, - phase: "connected", - }); - }); - - socket.close(1013, "try again later"); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - closeReason: "try again later", - hasConnected: true, - }); - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("reconnecting"); - - await transport.dispose(); - }); - - it("composes custom lifecycle handlers with default websocket state tracking", async () => { - const onOpen = vi.fn(); - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { - onOpen, - onClose, - }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(onOpen).toHaveBeenCalledOnce(); - expect(getWsConnectionStatus()).toMatchObject({ - hasConnected: true, - phase: "connected", - }); - }); - - socket.close(1012, "service restart"); - - await waitFor(() => { - expect(onClose).toHaveBeenCalledWith( - { - code: 1012, - reason: "service restart", - }, - { - intentional: false, - }, - ); - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 2, - closeReason: "service restart", - phase: "connecting", - }); - }, 2_000); - - await transport.dispose(); - }); - - it("marks unary requests as slow until the first server ack arrives", async () => { - const slowAckThresholdMs = 25; - setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - await waitFor(() => { - expect(getSlowRpcAckRequests()).toMatchObject([ - { - requestId: requestMessage.id, - tag: WS_METHODS.serverUpsertKeybinding, - }, - ]); - }, 1_000); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - expect(getSlowRpcAckRequests()).toEqual([]); - - await transport.dispose(); - }, 5_000); - - it("clears slow unary request tracking when the transport reconnects", async () => { - const slowAckThresholdMs = 25; - setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await waitFor(() => { - expect(firstSocket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; - - await waitFor(() => { - expect(getSlowRpcAckRequests()).toMatchObject([ - { - requestId: firstRequest.id, - tag: WS_METHODS.serverUpsertKeybinding, - }, - ]); - }, 1_000); - - void requestPromise.catch(() => undefined); - - await transport.reconnect(); - - expect(getSlowRpcAckRequests()).toEqual([]); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - secondSocket.open(); - - await transport.dispose(); - }, 5_000); - - it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { - await configureClientTracing({ - exportIntervalMs: 10, - }); - - const transport = createTransport("ws://localhost:3020"); - const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { - id: string; - spanId?: string; - traceId?: string; - }; - expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); - expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: encodeServerSettings(DEFAULT_SERVER_SETTINGS), - }, - }), - ); - - await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); - await transport.dispose(); - }); -}); diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts deleted file mode 100644 index 7c3b4303f3a..00000000000 --- a/apps/web/src/rpc/wsTransport.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - WsTransport as BaseWsTransport, - type WsProtocolLifecycleHandlers, - type WsRpcProtocolSocketUrlProvider, - type WsTransportOptions, -} from "@t3tools/client-runtime"; -import { createWsRpcProtocolLayer as createSharedWsRpcProtocolLayer } from "@t3tools/client-runtime"; - -import { ClientTracingLive } from "../observability/clientTracing"; -import { - acknowledgeRpcRequest, - clearAllTrackedRpcRequests, - trackRpcRequestSent, -} from "./requestLatencyState"; -import { - recordWsConnectionAttempt, - recordWsConnectionClosed, - recordWsConnectionErrored, - recordWsConnectionOpened, -} from "./wsConnectionState"; - -function createWsRpcProtocolLayer( - url: WsRpcProtocolSocketUrlProvider, - handlers?: WsProtocolLifecycleHandlers, -) { - return createSharedWsRpcProtocolLayer(url, handlers, { - telemetryLifecycle: { - onAttempt: recordWsConnectionAttempt, - onOpen: recordWsConnectionOpened, - onError: (message) => { - clearAllTrackedRpcRequests(); - recordWsConnectionErrored(message); - }, - onClose: (details, context) => { - clearAllTrackedRpcRequests(); - if (context.intentional) { - return; - } - recordWsConnectionClosed(details); - }, - }, - requestTelemetry: { - onRequestSent: trackRpcRequestSent, - onRequestAcknowledged: acknowledgeRpcRequest, - onClearTrackedRequests: clearAllTrackedRpcRequests, - }, - }); -} - -const webWsTransportOptions = { - tracingLayer: ClientTracingLive, - createProtocolLayer: createWsRpcProtocolLayer, - onBeforeReconnect: () => clearAllTrackedRpcRequests(), -} satisfies WsTransportOptions; - -export class WsTransport extends BaseWsTransport { - constructor( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - ) { - super(url, lifecycleHandlers, webWsTransportOptions); - } -} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index beb40aadff9..0f12e672f66 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1501,6 +1501,8 @@ describe("deriveTimelineEntries", () => { role: "assistant", text: "hello", createdAt: "2026-02-23T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-23T00:00:01.000Z", streaming: false, }, ], @@ -1586,7 +1588,7 @@ describe("isLatestTurnSettled", () => { it("returns false while the same turn is still active in a running session", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-1"), }), ).toBe(false); @@ -1595,7 +1597,7 @@ describe("isLatestTurnSettled", () => { it("returns false while any turn is running to avoid stale latest-turn banners", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-2"), }), ).toBe(false); @@ -1604,8 +1606,8 @@ describe("isLatestTurnSettled", () => { it("returns true once the session is no longer running that turn", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "ready", - activeTurnId: undefined, + status: "ready", + activeTurnId: null, }), ).toBe(true); }); @@ -1636,7 +1638,7 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-1"), }, "2026-02-27T21:11:00.000Z", @@ -1649,7 +1651,7 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-2"), }, "2026-02-27T21:11:00.000Z", @@ -1662,8 +1664,8 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "ready", - activeTurnId: undefined, + status: "ready", + activeTurnId: null, }, "2026-02-27T21:11:00.000Z", ), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 51907cf74ba..79ffa18ad7e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -296,7 +296,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str } type LatestTurnTiming = Pick; -type SessionActivityState = Pick; +type SessionActivityState = Pick, "status" | "activeTurnId">; export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, @@ -305,7 +305,7 @@ export function isLatestTurnSettled( if (!latestTurn?.startedAt) return false; if (!latestTurn.completedAt) return false; if (!session) return true; - if (session.orchestrationStatus === "running") return false; + if (session.status === "running") return false; return true; } @@ -314,8 +314,7 @@ export function deriveActiveWorkStartedAt( session: SessionActivityState | null, sendStartedAt: string | null, ): string | null { - const runningTurnId = - session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null; + const runningTurnId = session?.status === "running" ? session.activeTurnId : null; if (runningTurnId !== null) { if (latestTurn?.turnId === runningTurnId) { return latestTurn.startedAt ?? sendStartedAt; @@ -1350,9 +1349,9 @@ function compareActivityLifecycleRank(kind: string): number { } export function deriveTimelineEntries( - messages: ChatMessage[], - proposedPlans: ProposedPlan[], - workEntries: WorkLogEntry[], + messages: ReadonlyArray, + proposedPlans: ReadonlyArray, + workEntries: ReadonlyArray, ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1392,7 +1391,7 @@ export function deriveTimelineEntries( } export function inferCheckpointTurnCountByTurnId( - summaries: TurnDiffSummary[], + summaries: ReadonlyArray, ): Record { const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); const result: Record = {}; @@ -1405,8 +1404,15 @@ export function inferCheckpointTurnCountByTurnId( } export function derivePhase(session: ThreadSession | null): SessionPhase { - if (!session || session.status === "closed") return "disconnected"; - if (session.status === "connecting") return "connecting"; + if ( + !session || + session.status === "stopped" || + session.status === "interrupted" || + session.status === "error" + ) { + return "disconnected"; + } + if (session.status === "starting") return "connecting"; if (session.status === "running") return "running"; return "ready"; } diff --git a/apps/web/src/shortcutModifierState.test.ts b/apps/web/src/shortcutModifierState.test.ts index c506cba472c..cb62d45bcc0 100644 --- a/apps/web/src/shortcutModifierState.test.ts +++ b/apps/web/src/shortcutModifierState.test.ts @@ -2,12 +2,17 @@ import { describe, expect, it } from "vite-plus/test"; import { areShortcutModifierStatesEqual, - clearShortcutModifierState, - readShortcutModifierState, - setShortcutModifierState, - syncShortcutModifierStateFromKeyboardEvent, + shortcutModifierStateAfterKeyboardEvent, + type ShortcutModifierState, } from "./shortcutModifierState"; +const emptyState = (): ShortcutModifierState => ({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, +}); + function keyboardEventLike(type: "keydown" | "keyup", init: Partial): KeyboardEvent { return { type, @@ -36,107 +41,69 @@ describe("shortcutModifierState", () => { ).toBe(false); }); - it("preserves the current store object when modifier values do not change", () => { - clearShortcutModifierState(); - - const initialState = readShortcutModifierState(); - setShortcutModifierState({ - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }); - - expect(readShortcutModifierState()).toBe(initialState); - - setShortcutModifierState({ - metaKey: false, - ctrlKey: true, - altKey: false, - shiftKey: false, - }); - const updatedState = readShortcutModifierState(); - expect(updatedState).not.toBe(initialState); - expect(updatedState).toEqual({ - metaKey: false, - ctrlKey: true, - altKey: false, - shiftKey: false, - }); - - setShortcutModifierState({ - metaKey: false, - ctrlKey: true, - altKey: false, - shiftKey: false, - }); - expect(readShortcutModifierState()).toBe(updatedState); - - clearShortcutModifierState(); - const clearedState = readShortcutModifierState(); - expect(clearedState).toEqual({ - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }); - expect(clearedState).not.toBe(updatedState); - - clearShortcutModifierState(); - expect(readShortcutModifierState()).toBe(clearedState); + it("preserves the current object when modifier values do not change", () => { + const initialState = emptyState(); + const nextState = shortcutModifierStateAfterKeyboardEvent( + initialState, + keyboardEventLike("keyup", { key: "Shift" }), + ); + expect(nextState).toBe(initialState); }); it("tracks bare modifier keydown and keyup events explicitly", () => { - clearShortcutModifierState(); - - syncShortcutModifierStateFromKeyboardEvent( + let state = emptyState(); + state = shortcutModifierStateAfterKeyboardEvent( + state, keyboardEventLike("keydown", { key: "Meta", metaKey: false, }), ); - expect(readShortcutModifierState()).toEqual({ + expect(state).toEqual({ metaKey: true, ctrlKey: false, altKey: false, shiftKey: false, }); - syncShortcutModifierStateFromKeyboardEvent( + state = shortcutModifierStateAfterKeyboardEvent( + state, keyboardEventLike("keydown", { key: "Shift", metaKey: true, shiftKey: false, }), ); - expect(readShortcutModifierState()).toEqual({ + expect(state).toEqual({ metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, }); - syncShortcutModifierStateFromKeyboardEvent( + state = shortcutModifierStateAfterKeyboardEvent( + state, keyboardEventLike("keyup", { key: "Meta", metaKey: true, shiftKey: true, }), ); - expect(readShortcutModifierState()).toEqual({ + expect(state).toEqual({ metaKey: false, ctrlKey: false, altKey: false, shiftKey: true, }); - syncShortcutModifierStateFromKeyboardEvent( + state = shortcutModifierStateAfterKeyboardEvent( + state, keyboardEventLike("keyup", { key: "Shift", shiftKey: true, }), ); - expect(readShortcutModifierState()).toEqual({ + expect(state).toEqual({ metaKey: false, ctrlKey: false, altKey: false, diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts index 370f3b0a70c..a56a1a129d0 100644 --- a/apps/web/src/shortcutModifierState.ts +++ b/apps/web/src/shortcutModifierState.ts @@ -1,4 +1,4 @@ -import { create } from "zustand"; +import { useEffect, useState } from "react"; export interface ShortcutModifierState { metaKey: boolean; @@ -26,24 +26,32 @@ export function areShortcutModifierStatesEqual( ); } -const useShortcutModifierStateStore = create<{ - state: ShortcutModifierState; - setState: (state: ShortcutModifierState) => void; - clear: () => void; -}>((set) => ({ - state: EMPTY_SHORTCUT_MODIFIER_STATE, - setState: (state) => - set((current) => (areShortcutModifierStatesEqual(current.state, state) ? current : { state })), - clear: () => - set((current) => - areShortcutModifierStatesEqual(current.state, EMPTY_SHORTCUT_MODIFIER_STATE) - ? current - : { state: EMPTY_SHORTCUT_MODIFIER_STATE }, - ), -})); - export function useShortcutModifierState(): ShortcutModifierState { - return useShortcutModifierStateStore((store) => store.state); + const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE); + + useEffect(() => { + const onKeyboardEvent = (event: KeyboardEvent) => { + setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event)); + }; + const onWindowBlur = () => { + setState((current) => + areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE) + ? current + : EMPTY_SHORTCUT_MODIFIER_STATE, + ); + }; + + window.addEventListener("keydown", onKeyboardEvent, true); + window.addEventListener("keyup", onKeyboardEvent, true); + window.addEventListener("blur", onWindowBlur); + return () => { + window.removeEventListener("keydown", onKeyboardEvent, true); + window.removeEventListener("keyup", onKeyboardEvent, true); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); + + return state; } function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { @@ -64,33 +72,25 @@ function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { } } -export function syncShortcutModifierStateFromKeyboardEvent(event: KeyboardEvent): void { +export function shortcutModifierStateAfterKeyboardEvent( + currentState: ShortcutModifierState, + event: KeyboardEvent, +): ShortcutModifierState { const normalizedModifierKey = normalizeModifierKey(event.key); + let nextState: ShortcutModifierState; if (normalizedModifierKey) { - const currentState = useShortcutModifierStateStore.getState().state; - useShortcutModifierStateStore.getState().setState({ + nextState = { ...currentState, [normalizedModifierKey]: event.type === "keydown", - }); - return; + }; + } else { + nextState = { + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + }; } - useShortcutModifierStateStore.getState().setState({ - metaKey: event.metaKey, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - shiftKey: event.shiftKey, - }); -} - -export function setShortcutModifierState(state: ShortcutModifierState): void { - useShortcutModifierStateStore.getState().setState(state); -} - -export function clearShortcutModifierState(): void { - useShortcutModifierStateStore.getState().clear(); -} - -export function readShortcutModifierState(): ShortcutModifierState { - return useShortcutModifierStateStore.getState().state; + return areShortcutModifierStatesEqual(currentState, nextState) ? currentState : nextState; } diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8909c1bf755..c90cf51d969 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; import { deriveLogicalProjectKeyFromSettings, @@ -104,7 +104,7 @@ export function buildSidebarProjectSnapshots(input: { representative, members, }) - : representative.name, + : representative.title, groupedProjectCount: members.length, environmentPresence: hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts new file mode 100644 index 00000000000..5e31beb826b --- /dev/null +++ b/apps/web/src/state/assets.ts @@ -0,0 +1,5 @@ +import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/auth.ts b/apps/web/src/state/auth.ts new file mode 100644 index 00000000000..835dee7f783 --- /dev/null +++ b/apps/web/src/state/auth.ts @@ -0,0 +1,5 @@ +import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/desktopNetworkAccess.test.ts b/apps/web/src/state/desktopNetworkAccess.test.ts new file mode 100644 index 00000000000..0dde5f7d7dc --- /dev/null +++ b/apps/web/src/state/desktopNetworkAccess.test.ts @@ -0,0 +1,87 @@ +import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createDesktopNetworkAccessStateAtom } from "./desktopNetworkAccess"; + +const serverExposureState: DesktopServerExposureState = { + advertisedHost: "192.168.1.10", + endpointUrl: "http://192.168.1.10:37737", + mode: "network-accessible", + tailscaleServeEnabled: false, + tailscaleServePort: 443, +}; + +const advertisedEndpoints: ReadonlyArray = []; +const serverExposureLoadCause = new Error("exposure failed"); +const advertisedEndpointsLoadCause = new Error("endpoints failed"); + +describe("desktopNetworkAccessState", () => { + it("retains the loaded snapshot when the settings screen remounts", async () => { + const getServerExposureState = vi.fn(async () => serverExposureState); + const getAdvertisedEndpoints = vi.fn(async () => advertisedEndpoints); + const atom = createDesktopNetworkAccessStateAtom(() => ({ + getAdvertisedEndpoints, + getServerExposureState, + })); + const registry = AtomRegistry.make(); + + const unmount = registry.mount(atom); + await vi.waitFor(() => { + expect(AsyncResult.value(registry.get(atom))).toEqual( + expect.objectContaining({ _tag: "Some" }), + ); + }); + unmount(); + + const remount = registry.mount(atom); + const result = registry.get(atom); + expect(AsyncResult.value(result)).toEqual( + expect.objectContaining({ + _tag: "Some", + value: { advertisedEndpoints, serverExposureState }, + }), + ); + expect(getServerExposureState).toHaveBeenCalledTimes(1); + expect(getAdvertisedEndpoints).toHaveBeenCalledTimes(1); + + remount(); + registry.dispose(); + }); + + it.each([ + { + cause: serverExposureLoadCause, + expectedTag: "DesktopServerExposureStateLoadError", + getAdvertisedEndpoints: async () => advertisedEndpoints, + getServerExposureState: async () => Promise.reject(serverExposureLoadCause), + }, + { + cause: advertisedEndpointsLoadCause, + expectedTag: "DesktopAdvertisedEndpointsLoadError", + getAdvertisedEndpoints: async () => Promise.reject(advertisedEndpointsLoadCause), + getServerExposureState: async () => serverExposureState, + }, + ])("retains the $expectedTag cause", async (testCase) => { + const atom = createDesktopNetworkAccessStateAtom(() => ({ + getAdvertisedEndpoints: testCase.getAdvertisedEndpoints, + getServerExposureState: testCase.getServerExposureState, + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + if (!AsyncResult.isFailure(result)) throw new Error("Expected network access load to fail."); + + expect(Cause.squash(result.cause)).toEqual( + expect.objectContaining({ + _tag: testCase.expectedTag, + cause: testCase.cause, + }), + ); + registry.dispose(); + }); +}); diff --git a/apps/web/src/state/desktopNetworkAccess.ts b/apps/web/src/state/desktopNetworkAccess.ts new file mode 100644 index 00000000000..07580fcd164 --- /dev/null +++ b/apps/web/src/state/desktopNetworkAccess.ts @@ -0,0 +1,98 @@ +import type { + AdvertisedEndpoint, + DesktopBridge, + DesktopServerExposureState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +const DESKTOP_NETWORK_ACCESS_STALE_TIME_MS = 30_000; + +type DesktopNetworkAccessBridge = Pick< + DesktopBridge, + "getAdvertisedEndpoints" | "getServerExposureState" +>; + +export interface DesktopNetworkAccessSnapshot { + readonly advertisedEndpoints: ReadonlyArray; + readonly serverExposureState: DesktopServerExposureState; +} + +class DesktopNetworkAccessUnavailableError extends Schema.TaggedErrorClass()( + "DesktopNetworkAccessUnavailableError", + {}, +) { + override get message(): string { + return "Desktop network access is unavailable."; + } +} + +class DesktopServerExposureStateLoadError extends Schema.TaggedErrorClass()( + "DesktopServerExposureStateLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load desktop server exposure state."; + } +} + +class DesktopAdvertisedEndpointsLoadError extends Schema.TaggedErrorClass()( + "DesktopAdvertisedEndpointsLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load advertised desktop endpoints."; + } +} + +function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { + return typeof window === "undefined" ? undefined : window.desktopBridge; +} + +export function createDesktopNetworkAccessStateAtom( + getBridge: () => DesktopNetworkAccessBridge | undefined, +) { + const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { + const bridge = getBridge(); + if (!bridge) { + return yield* new DesktopNetworkAccessUnavailableError(); + } + const [serverExposureState, advertisedEndpoints] = yield* Effect.all( + [ + Effect.tryPromise({ + try: () => bridge.getServerExposureState(), + catch: (cause) => new DesktopServerExposureStateLoadError({ cause }), + }), + Effect.tryPromise({ + try: () => bridge.getAdvertisedEndpoints(), + catch: (cause) => new DesktopAdvertisedEndpointsLoadError({ cause }), + }), + ], + { concurrency: "unbounded" }, + ); + return { + advertisedEndpoints, + serverExposureState, + } satisfies DesktopNetworkAccessSnapshot; + }); + + return Atom.make(loadDesktopNetworkAccess()).pipe( + Atom.swr({ + staleTime: DESKTOP_NETWORK_ACCESS_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.keepAlive, + Atom.withLabel("desktop:network-access"), + ); +} + +export const desktopNetworkAccessStateAtom = createDesktopNetworkAccessStateAtom( + getDesktopNetworkAccessBridge, +); + +export function refreshDesktopNetworkAccessState(): void { + appAtomRegistry.refresh(desktopNetworkAccessStateAtom); +} diff --git a/apps/web/src/state/desktopSshHosts.test.ts b/apps/web/src/state/desktopSshHosts.test.ts new file mode 100644 index 00000000000..83eda60158c --- /dev/null +++ b/apps/web/src/state/desktopSshHosts.test.ts @@ -0,0 +1,63 @@ +import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AtomRegistry } from "effect/unstable/reactivity"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createDesktopSshHostsStateAtom } from "./desktopSshHosts"; + +const hosts: ReadonlyArray = [ + { + alias: "devbox", + hostname: "devbox.local", + port: null, + source: "ssh-config", + username: null, + }, +]; + +describe("desktopSshHostsState", () => { + it("retains discovered hosts when the settings screen remounts", async () => { + const discoverSshHosts = vi.fn(async () => hosts); + const atom = createDesktopSshHostsStateAtom(() => ({ discoverSshHosts })); + const registry = AtomRegistry.make(); + + const unmount = registry.mount(atom); + await vi.waitFor(() => { + expect(AsyncResult.value(registry.get(atom))).toEqual( + expect.objectContaining({ _tag: "Some", value: hosts }), + ); + }); + unmount(); + + const remount = registry.mount(atom); + expect(AsyncResult.value(registry.get(atom))).toEqual( + expect.objectContaining({ _tag: "Some", value: hosts }), + ); + expect(discoverSshHosts).toHaveBeenCalledTimes(1); + + remount(); + registry.dispose(); + }); + + it("retains the desktop bridge failure as the discovery error cause", async () => { + const cause = new Error("ssh config unavailable"); + const atom = createDesktopSshHostsStateAtom(() => ({ + discoverSshHosts: async () => Promise.reject(cause), + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + if (!AsyncResult.isFailure(result)) throw new Error("Expected SSH host discovery to fail."); + + expect(Cause.squash(result.cause)).toEqual( + expect.objectContaining({ + _tag: "DesktopSshDiscoveryError", + cause, + }), + ); + registry.dispose(); + }); +}); diff --git a/apps/web/src/state/desktopSshHosts.ts b/apps/web/src/state/desktopSshHosts.ts new file mode 100644 index 00000000000..8e4022cbecf --- /dev/null +++ b/apps/web/src/state/desktopSshHosts.ts @@ -0,0 +1,53 @@ +import type { DesktopBridge, DesktopDiscoveredSshHost } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; + +type DesktopSshDiscoveryBridge = Pick; + +class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass()( + "DesktopSshDiscoveryUnavailableError", + {}, +) { + override get message(): string { + return "Desktop SSH host discovery is unavailable."; + } +} + +class DesktopSshDiscoveryError extends Schema.TaggedErrorClass()( + "DesktopSshDiscoveryError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to discover SSH hosts."; + } +} + +function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { + return typeof window === "undefined" ? undefined : window.desktopBridge; +} + +export function createDesktopSshHostsStateAtom( + getBridge: () => DesktopSshDiscoveryBridge | undefined, +) { + const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { + const bridge = getBridge(); + if (!bridge) { + return yield* new DesktopSshDiscoveryUnavailableError(); + } + return yield* Effect.tryPromise({ + try: (): Promise> => bridge.discoverSshHosts(), + catch: (cause) => new DesktopSshDiscoveryError({ cause }), + }); + }); + + return Atom.make(discoverDesktopSshHosts()).pipe( + Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), + Atom.keepAlive, + Atom.withLabel("desktop:ssh-hosts"), + ); +} + +export const desktopSshHostsStateAtom = createDesktopSshHostsStateAtom( + getDesktopSshDiscoveryBridge, +); diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts new file mode 100644 index 00000000000..a2bcbd19a33 --- /dev/null +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -0,0 +1,134 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createDesktopUpdateStateAtom, DesktopUpdateStateReadError } from "./desktopUpdate"; + +const baseState: DesktopUpdateState = { + enabled: true, + status: "idle", + channel: "latest", + currentVersion: "1.0.0", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("desktopUpdateStateAtom", () => { + it("loads once, retains state, and follows desktop update events", async () => { + let listener: ((state: DesktopUpdateState) => void) | undefined; + const unsubscribe = vi.fn(); + const getUpdateState = vi.fn(async () => baseState); + const onUpdateState = vi.fn((nextListener: (state: DesktopUpdateState) => void) => { + listener = nextListener; + return unsubscribe; + }); + const atom = createDesktopUpdateStateAtom(() => ({ getUpdateState, onUpdateState })); + const registry = AtomRegistry.make(); + + const unmount = registry.mount(atom); + await vi.waitFor(() => { + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState); + }); + + const downloadedState: DesktopUpdateState = { + ...baseState, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }; + listener?.(downloadedState); + + await vi.waitFor(() => { + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(downloadedState); + }); + unmount(); + + const remount = registry.mount(atom); + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(downloadedState); + expect(getUpdateState).toHaveBeenCalledTimes(1); + expect(onUpdateState).toHaveBeenCalledTimes(1); + + remount(); + registry.dispose(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("does not let a slower initial read overwrite a newer update event", async () => { + let resolveInitial: ((state: DesktopUpdateState) => void) | undefined; + let listener: ((state: DesktopUpdateState) => void) | undefined; + const atom = createDesktopUpdateStateAtom(() => ({ + getUpdateState: () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + onUpdateState: (nextListener) => { + listener = nextListener; + return () => undefined; + }, + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(listener).toBeDefined()); + const newerState: DesktopUpdateState = { ...baseState, status: "checking" }; + listener?.(newerState); + resolveInitial?.(baseState); + + await vi.waitFor(() => { + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(newerState); + }); + registry.dispose(); + }); + + it("keeps listening when the initial desktop state read fails", async () => { + let listener: ((state: DesktopUpdateState) => void) | undefined; + const cause = new Error("IPC unavailable"); + const reportError = vi.spyOn(console, "log").mockImplementation(() => undefined); + const getUpdateState = vi.fn(async () => Promise.reject(cause)); + const atom = createDesktopUpdateStateAtom(() => ({ + getUpdateState, + onUpdateState: (nextListener) => { + listener = nextListener; + return () => undefined; + }, + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(listener).toBeDefined()); + await vi.waitFor(() => expect(reportError).toHaveBeenCalledOnce()); + expect(getUpdateState).toHaveBeenCalledTimes(3); + const [, errorMessage, errorContext] = reportError.mock.calls[0] ?? []; + expect(errorMessage).toBe("Failed to read the initial desktop update state after 3 attempts."); + expect(errorContext).toMatchObject({ + errorTag: "DesktopUpdateStateReadError", + attemptCount: 3, + }); + const loggedError = (errorContext as { readonly error: unknown }).error; + expect(loggedError).toBeInstanceOf(DesktopUpdateStateReadError); + expect(loggedError).toMatchObject({ + _tag: "DesktopUpdateStateReadError", + attemptCount: 3, + }); + expect((loggedError as DesktopUpdateStateReadError).cause).toBe(cause); + + listener?.(baseState); + await vi.waitFor(() => { + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState); + }); + registry.dispose(); + }); +}); diff --git a/apps/web/src/state/desktopUpdate.ts b/apps/web/src/state/desktopUpdate.ts new file mode 100644 index 00000000000..a7b9ed483b6 --- /dev/null +++ b/apps/web/src/state/desktopUpdate.ts @@ -0,0 +1,86 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { Atom } from "effect/unstable/reactivity"; + +type DesktopUpdateBridge = Pick; + +const INITIAL_STATE_READ_ATTEMPT_COUNT = 3; + +export class DesktopUpdateStateReadError extends Schema.TaggedErrorClass()( + "DesktopUpdateStateReadError", + { + attemptCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the initial desktop update state after ${this.attemptCount} attempts.`; + } +} + +function getDesktopUpdateBridge(): DesktopUpdateBridge | undefined { + return typeof window === "undefined" ? undefined : window.desktopBridge; +} + +export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridge | undefined) { + const updates = Stream.callback((queue) => + Effect.gen(function* () { + const bridge = getBridge(); + if (!bridge) { + Queue.offerUnsafe(queue, null); + return yield* Effect.never; + } + + let receivedUpdate = false; + yield* Effect.acquireRelease( + Effect.sync(() => + bridge.onUpdateState((state) => { + receivedUpdate = true; + Queue.offerUnsafe(queue, state); + }), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); + + const initialState = yield* Effect.tryPromise({ + try: () => bridge.getUpdateState(), + catch: (cause) => + new DesktopUpdateStateReadError({ + attemptCount: INITIAL_STATE_READ_ATTEMPT_COUNT, + cause, + }), + }).pipe( + Effect.retry({ times: INITIAL_STATE_READ_ATTEMPT_COUNT - 1 }), + Effect.catchTags({ + DesktopUpdateStateReadError: (error) => + Effect.logError(error.message, { + error, + errorTag: error._tag, + attemptCount: error.attemptCount, + }).pipe(Effect.as(null)), + }), + ); + if (!receivedUpdate && initialState !== null) { + Queue.offerUnsafe(queue, initialState); + } + + return yield* Effect.never; + }), + ); + + return Atom.make(updates, { initialValue: null }).pipe( + Atom.keepAlive, + Atom.withLabel("desktop:update-state"), + ); +} + +const desktopUpdateStateAtom = createDesktopUpdateStateAtom(getDesktopUpdateBridge); + +export function useDesktopUpdateState(): DesktopUpdateState | null { + return AsyncResult.getOrElse(useAtomValue(desktopUpdateStateAtom), () => null); +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts new file mode 100644 index 00000000000..b4fc8cc5e80 --- /dev/null +++ b/apps/web/src/state/entities.ts @@ -0,0 +1,203 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentProject, + EnvironmentThread, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads"; +import type { + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, + OrchestrationThreadActivity, + ScopedProjectRef, + ScopedThreadRef, + ServerConfig, +} from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentProjects } from "./projects"; +import { environmentServerConfigsAtom } from "./server"; +import { environmentThreadDetails, environmentThreadShells } from "./threads"; + +const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_REFS: ReadonlyArray = Object.freeze([]); +const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); +const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); +const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); + +const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-project:empty"), +); +const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( + Atom.withLabel("web-project-refs:empty"), +); +const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( + Atom.withLabel("web-thread-refs:empty"), +); +const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-shell:empty"), +); +const EMPTY_THREAD_DETAIL_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-detail:empty"), +); +const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( + Atom.withLabel("web-thread-messages:empty"), +); +const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe( + Atom.withLabel("web-thread-activities:empty"), +); +const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( + Atom.withLabel("web-thread-proposed-plans:empty"), +); +const EMPTY_SESSION_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-session:empty"), +); + +export const activeEnvironmentIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("web-active-environment-id"), +); + +export function useActiveEnvironmentId(): EnvironmentId | null { + return useAtomValue(activeEnvironmentIdAtom); +} + +export function readActiveEnvironmentId(): EnvironmentId | null { + return appAtomRegistry.get(activeEnvironmentIdAtom); +} + +export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void { + appAtomRegistry.set(activeEnvironmentIdAtom, environmentId); +} + +export function useProjectRefs(): ReadonlyArray { + return useAtomValue(environmentProjects.projectRefsAtom); +} + +export function useThreadRefs(): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadRefsAtom); +} + +export function useEnvironmentProjectRefs( + environmentId: EnvironmentId | null, +): ReadonlyArray { + return useAtomValue( + environmentId === null + ? EMPTY_PROJECT_REFS_ATOM + : environmentProjects.environmentProjectRefsAtom(environmentId), + ); +} + +export function useEnvironmentThreadRefs( + environmentId: EnvironmentId | null, +): ReadonlyArray { + return useAtomValue( + environmentId === null + ? EMPTY_THREAD_REFS_ATOM + : environmentThreadShells.environmentThreadRefsAtom(environmentId), + ); +} + +export function useProjects(): ReadonlyArray { + return useAtomValue(environmentProjects.projectsAtom); +} + +export function useServerConfigs(): ReadonlyMap { + return useAtomValue(environmentServerConfigsAtom); +} + +export function useThreadShells(): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadShellsAtom); +} + +export function useThreadShellsForProjectRefs( + refs: ReadonlyArray, +): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); +} + +export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { + return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); +} + +export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref), + ); +} + +export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_DETAIL_ATOM : environmentThreadDetails.detailAtom(ref), + ); +} + +/** Detail collections composed with shell-authoritative thread/workspace metadata. */ +export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { + const shell = useThreadShell(ref); + const detail = useThreadDetail(ref); + return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]); +} + +export function useThreadMessages( + ref: ScopedThreadRef | null, +): ReadonlyArray { + return useAtomValue( + ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), + ); +} + +export function useThreadActivities( + ref: ScopedThreadRef | null, +): ReadonlyArray { + return useAtomValue( + ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref), + ); +} + +export function useThreadProposedPlans( + ref: ScopedThreadRef | null, +): ReadonlyArray { + return useAtomValue( + ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref), + ); +} + +export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null { + return useAtomValue( + ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref), + ); +} + +export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { + return appAtomRegistry.get(environmentProjects.projectAtom(ref)); +} + +export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { + return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); +} + +export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { + return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); +} + +export function readEnvironmentThreadRefs( + environmentId: EnvironmentId, +): ReadonlyArray { + return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); +} + +export function readThreadRefs(): ReadonlyArray { + return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); +} + +export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { + return ( + appAtomRegistry + .get(environmentThreadShells.threadRefsAtom) + .find((ref) => ref.threadId === threadId) ?? null + ); +} diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts new file mode 100644 index 00000000000..443e99b84cd --- /dev/null +++ b/apps/web/src/state/environments.ts @@ -0,0 +1,91 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + type EnvironmentPresentation as BaseEnvironmentPresentation, +} from "@t3tools/client-runtime/connection"; +import { Discovery } from "@t3tools/client-runtime/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useMemo } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { useEnvironmentQuery } from "./query"; +import { relayEnvironmentDiscovery } from "./relay"; +import { usePreparedConnection } from "./session"; + +export interface EnvironmentPresentation extends BaseEnvironmentPresentation { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly displayUrl: string | null; + readonly relayManaged: boolean; +} + +function projectEnvironmentPresentation( + environmentId: EnvironmentId, + presentation: BaseEnvironmentPresentation, +): EnvironmentPresentation { + return { + ...presentation, + environmentId, + label: presentation.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(presentation.entry), + relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + }; +} + +export function useEnvironments() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); + const presentationById = useAtomValue(environmentPresentations.presentationsAtom); + + const environments = useMemo( + () => + [...presentationById.entries()].map(([environmentId, presentation]) => + projectEnvironmentPresentation(environmentId, presentation), + ), + [presentationById], + ); + + return { + isReady: catalog.isReady, + networkStatus, + environments, + presentationById, + }; +} + +export function usePrimaryEnvironmentId(): EnvironmentId | null { + return useAtomValue(primaryEnvironmentIdAtom); +} + +export function useEnvironment( + environmentId: EnvironmentId | null, +): EnvironmentPresentation | null { + const { presentation } = useEnvironmentPresentation(environmentId); + return useMemo( + () => + environmentId === null || presentation === null + ? null + : projectEnvironmentPresentation(environmentId, presentation), + [environmentId, presentation], + ); +} + +export function usePrimaryEnvironment(): EnvironmentPresentation | null { + return useEnvironment(usePrimaryEnvironmentId()); +} + +export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): string | null { + const prepared = usePreparedConnection(environmentId); + return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null; +} + +export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { + return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); +} + +export function useEnvironmentConnectionState(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); +} diff --git a/apps/web/src/state/filesystem.ts b/apps/web/src/state/filesystem.ts new file mode 100644 index 00000000000..19d5b53c4e0 --- /dev/null +++ b/apps/web/src/state/filesystem.ts @@ -0,0 +1,5 @@ +import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/git.ts b/apps/web/src/state/git.ts new file mode 100644 index 00000000000..66bb3dc0bde --- /dev/null +++ b/apps/web/src/state/git.ts @@ -0,0 +1,5 @@ +import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/orchestration.ts b/apps/web/src/state/orchestration.ts new file mode 100644 index 00000000000..8c6e1738857 --- /dev/null +++ b/apps/web/src/state/orchestration.ts @@ -0,0 +1,5 @@ +import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/presentation.ts b/apps/web/src/state/presentation.ts new file mode 100644 index 00000000000..1c2fb7b6a62 --- /dev/null +++ b/apps/web/src/state/presentation.ts @@ -0,0 +1,31 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; +import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { serverEnvironment } from "./server"; + +export const environmentPresentations = createEnvironmentPresentationAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + stateAtom: environmentCatalog.stateAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, +}); + +const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-environment-presentation:empty"), +); + +export function useEnvironmentPresentation(environmentId: EnvironmentId | null) { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const presentation = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM + : environmentPresentations.presentationAtom(environmentId), + ); + return { + isReady: catalog.isReady, + presentation, + }; +} diff --git a/apps/web/src/state/preview.ts b/apps/web/src/state/preview.ts new file mode 100644 index 00000000000..af15f38ab77 --- /dev/null +++ b/apps/web/src/state/preview.ts @@ -0,0 +1,5 @@ +import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/primaryEnvironment.ts b/apps/web/src/state/primaryEnvironment.ts new file mode 100644 index 00000000000..e37931f74a8 --- /dev/null +++ b/apps/web/src/state/primaryEnvironment.ts @@ -0,0 +1,12 @@ +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; + +export const primaryEnvironmentIdAtom = Atom.make((get) => { + for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { + if (entry.target._tag === "PrimaryConnectionTarget") { + return environmentId; + } + } + return null; +}).pipe(Atom.withLabel("web-primary-environment-id")); diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts new file mode 100644 index 00000000000..7a879988328 --- /dev/null +++ b/apps/web/src/state/projects.ts @@ -0,0 +1,12 @@ +import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; +import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts new file mode 100644 index 00000000000..79737e6109e --- /dev/null +++ b/apps/web/src/state/queries.ts @@ -0,0 +1,257 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type CheckpointDiffTarget, + type ComposerPathSearchTarget, +} from "@t3tools/client-runtime/state/threads"; +import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; +import type { + EnvironmentId, + OrchestrationThread, + ThreadId, + VcsListRefsResult, + VcsRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { orchestrationEnvironment } from "./orchestration"; +import { projectEnvironment } from "./projects"; +import { useEnvironmentQuery } from "./query"; +import { useEnvironmentThread } from "./threads"; +import { vcsEnvironment } from "./vcs"; + +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; +const COMPOSER_PATH_SEARCH_LIMIT = 80; +const VCS_REF_LIST_LIMIT = 100; +const EMPTY_REFS: ReadonlyArray = []; +const INITIAL_BRANCH_CURSORS = [undefined] as const; + +export interface ThreadDetailView { + readonly data: OrchestrationThread | null; + readonly error: string | null; + readonly isPending: boolean; + readonly isDeleted: boolean; +} + +function useDebouncedValue(value: A, delayMs: number): A { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebounced(value); + }, delayMs); + return () => { + window.clearTimeout(timer); + }; + }, [delayMs, value]); + + return debounced; +} + +export function useThreadDetail( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): ThreadDetailView { + const state = useEnvironmentThread(environmentId, threadId); + return { + data: Option.getOrNull(state.data), + error: Option.getOrNull(state.error), + isPending: state.status === "synchronizing", + isDeleted: state.status === "deleted", + }; +} + +export function useBranches(target: VcsRefTarget) { + const query = target.query?.trim() ?? ""; + return useEnvironmentQuery( + target.environmentId !== null && target.cwd !== null + ? vcsEnvironment.listRefs({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + ...(query.length > 0 ? { query } : {}), + limit: VCS_REF_LIST_LIMIT, + }, + }) + : null, + ); +} + +export function usePaginatedBranches(target: VcsRefTarget) { + const query = target.query?.trim() ?? ""; + const targetKey = + target.environmentId !== null && target.cwd !== null + ? JSON.stringify([target.environmentId, target.cwd, query]) + : null; + const [pagination, setPagination] = useState<{ + readonly targetKey: string | null; + readonly cursors: ReadonlyArray; + }>({ + targetKey, + cursors: INITIAL_BRANCH_CURSORS, + }); + const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [cursors, query, target.cwd, target.environmentId], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`web:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], + ); + const results = useAtomValue(pagesAtom); + const values = results.flatMap((result) => { + const value = Option.getOrNull(AsyncResult.value(result)); + return value === null ? [] : [value]; + }); + const refs = new Map(); + for (const value of values) { + for (const ref of value.refs) { + refs.set(ref.name, ref); + } + } + const first = values[0] ?? null; + const last = values.at(-1) ?? null; + const data: VcsListRefsResult | null = + first === null || last === null + ? null + : { + refs: [...refs.values()], + isRepo: first.isRepo, + hasPrimaryRemote: first.hasPrimaryRemote, + nextCursor: last.nextCursor, + totalCount: Math.max(...values.map((value) => value.totalCount)), + }; + const failed = results.find((result) => result._tag === "Failure"); + const error = + failed?._tag === "Failure" + ? (() => { + const cause = Cause.squash(failed.cause); + return cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load refs."; + })() + : null; + const refresh = useCallback(() => { + const firstPage = pageAtoms[0]; + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + if (firstPage !== undefined) { + appAtomRegistry.refresh(firstPage); + } + }, [pageAtoms, targetKey]); + const loadNext = useCallback(() => { + if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) { + return; + } + setPagination((current) => { + const currentCursors = + current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS; + return currentCursors.includes(data.nextCursor!) + ? { targetKey, cursors: currentCursors } + : { targetKey, cursors: [...currentCursors, data.nextCursor!] }; + }); + }, [data?.nextCursor, targetKey]); + + return { + data, + refs: data?.refs ?? EMPTY_REFS, + error, + isPending: results.some((result) => result.waiting), + refresh, + loadNext, + }; +} + +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + const normalizedTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: target.query?.trim() ?? "", + }), + [target.cwd, target.environmentId, target.query], + ); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query.length > 0 + ? projectEnvironment.searchEntries({ + environmentId: debouncedTarget.environmentId, + input: { + cwd: debouncedTarget.cwd, + query: debouncedTarget.query, + limit: COMPOSER_PATH_SEARCH_LIMIT, + }, + }) + : null, + ); + + return { + entries: result.data?.entries ?? [], + error: result.error, + isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + refresh: result.refresh, + }; +} + +export function useCheckpointDiff( + target: CheckpointDiffTarget, + options?: { readonly enabled?: boolean }, +) { + const enabled = + options?.enabled !== false && + target.environmentId !== null && + target.threadId !== null && + target.fromTurnCount !== null && + target.toTurnCount !== null; + const fullThreadTarget = + enabled && target.fromTurnCount === 0 + ? { + environmentId: target.environmentId!, + input: { + threadId: target.threadId!, + toTurnCount: target.toTurnCount!, + ignoreWhitespace: target.ignoreWhitespace, + }, + } + : null; + const turnTarget = + enabled && target.fromTurnCount !== 0 + ? { + environmentId: target.environmentId!, + input: { + threadId: target.threadId!, + fromTurnCount: target.fromTurnCount!, + toTurnCount: target.toTurnCount!, + ignoreWhitespace: target.ignoreWhitespace, + }, + } + : null; + const fullThread = useEnvironmentQuery( + fullThreadTarget === null ? null : orchestrationEnvironment.fullThreadDiff(fullThreadTarget), + ); + const turn = useEnvironmentQuery( + turnTarget === null ? null : orchestrationEnvironment.turnDiff(turnTarget), + ); + return fullThreadTarget === null ? turn : fullThread; +} diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts new file mode 100644 index 00000000000..2610f1724a0 --- /dev/null +++ b/apps/web/src/state/query.ts @@ -0,0 +1,36 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("web-environment-query:empty"), +); + +export interface EnvironmentQueryView { + readonly data: A | null; + readonly error: string | null; + readonly isPending: boolean; + readonly refresh: () => void; +} + +function formatError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The environment request failed."; +} + +export function useEnvironmentQuery( + atom: Atom.Atom> | null, +): EnvironmentQueryView { + const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; + const result = useAtomValue(selectedAtom); + const refresh = useAtomRefresh(selectedAtom); + return { + data: Option.getOrNull(AsyncResult.value(result)), + error: result._tag === "Failure" ? formatError(result.cause) : null, + isPending: atom !== null && result.waiting, + refresh, + }; +} diff --git a/apps/web/src/state/relay.ts b/apps/web/src/state/relay.ts new file mode 100644 index 00000000000..3cbac7a1875 --- /dev/null +++ b/apps/web/src/state/relay.ts @@ -0,0 +1,6 @@ +import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const relayEnvironmentDiscovery: ReturnType = + createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/review.ts b/apps/web/src/state/review.ts new file mode 100644 index 00000000000..e4289d1f1d5 --- /dev/null +++ b/apps/web/src/state/review.ts @@ -0,0 +1,5 @@ +import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts new file mode 100644 index 00000000000..3271eefd1e1 --- /dev/null +++ b/apps/web/src/state/server.ts @@ -0,0 +1,100 @@ +import { + DEFAULT_SERVER_SETTINGS, + type EditorId, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleWelcomePayload, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; +import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { environmentSession } from "./session"; + +export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { + initialConfigValueAtom: environmentSession.initialConfigValueAtom, +}); +export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, +}); + +interface PrimaryServerState { + readonly config: ServerConfig | null; + readonly latestEvent: ServerConfigStreamEvent | null; + readonly welcome: ServerLifecycleWelcomePayload | null; +} + +const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; +const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; +const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { + config: null, + latestEvent: null, + welcome: null, +}; + +export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { + const environmentId = get(primaryEnvironmentIdAtom); + if (environmentId === null) { + return EMPTY_PRIMARY_SERVER_STATE; + } + + const target = { environmentId, input: {} }; + const configProjection = Option.getOrNull( + AsyncResult.value(get(serverEnvironment.configProjection(target))), + ); + const welcome = Option.getOrNull(AsyncResult.value(get(serverEnvironment.welcome(target)))); + + return { + config: get(serverEnvironment.configValueAtom(environmentId)), + latestEvent: configProjection?.latestEvent ?? null, + welcome, + }; +}).pipe(Atom.withLabel("web-primary-server-state")); + +export const primaryServerConfigAtom = Atom.make( + (get): ServerConfig | null => get(primaryServerStateAtom).config, +).pipe(Atom.withLabel("web-primary-server-config")); + +export const primaryServerConfigEventAtom = Atom.make( + (get): ServerConfigStreamEvent | null => get(primaryServerStateAtom).latestEvent, +).pipe(Atom.withLabel("web-primary-server-config-event")); + +export const primaryServerWelcomeAtom = Atom.make( + (get): ServerLifecycleWelcomePayload | null => get(primaryServerStateAtom).welcome, +).pipe(Atom.withLabel("web-primary-server-welcome")); + +export const primaryServerSettingsAtom = Atom.make( + (get): ServerSettings => get(primaryServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, +).pipe(Atom.withLabel("web-primary-server-settings")); + +export const primaryServerProvidersAtom = Atom.make( + (get): ReadonlyArray => + get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, +).pipe(Atom.withLabel("web-primary-server-providers")); + +export const primaryServerKeybindingsAtom = Atom.make( + (get): ServerConfig["keybindings"] => + get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, +).pipe(Atom.withLabel("web-primary-server-keybindings")); + +export const primaryServerAvailableEditorsAtom = Atom.make( + (get): ReadonlyArray => + get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS, +).pipe(Atom.withLabel("web-primary-server-available-editors")); + +export const primaryServerKeybindingsConfigPathAtom = Atom.make( + (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, +).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); + +export const primaryServerObservabilityAtom = Atom.make( + (get): ServerConfig["observability"] | null => + get(primaryServerConfigAtom)?.observability ?? null, +).pipe(Atom.withLabel("web-primary-server-observability")); diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts new file mode 100644 index 00000000000..37fed3b188f --- /dev/null +++ b/apps/web/src/state/session.ts @@ -0,0 +1,28 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); + +const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( + Atom.withLabel("web-prepared-connection:empty"), +); + +export function usePreparedConnection(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentId === null + ? EMPTY_PREPARED_CONNECTION_ATOM + : environmentSession.preparedConnectionValueAtom(environmentId), + ); +} + +export function readPreparedConnection(environmentId: EnvironmentId) { + return Option.getOrNull( + appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); +} diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts new file mode 100644 index 00000000000..e879dd25e29 --- /dev/null +++ b/apps/web/src/state/shell.ts @@ -0,0 +1,17 @@ +import { + createEnvironmentShellAtoms, + createEnvironmentShellSummaryAtom, + createEnvironmentSnapshotAtom, + createShellEnvironmentAtoms, +} from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); +export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); +export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); +export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, +}); diff --git a/apps/web/src/state/sourceControl.ts b/apps/web/src/state/sourceControl.ts new file mode 100644 index 00000000000..aa6255f85ff --- /dev/null +++ b/apps/web/src/state/sourceControl.ts @@ -0,0 +1,5 @@ +import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts new file mode 100644 index 00000000000..297ae5717df --- /dev/null +++ b/apps/web/src/state/sourceControlActions.ts @@ -0,0 +1,390 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + AtomCommandFailure, + AtomCommandResult, + AtomCommandSuccess, +} from "@t3tools/client-runtime/state/runtime"; +import { + VcsActionUnavailableError, + type VcsActionOperation, +} from "@t3tools/client-runtime/state/vcs"; +import type { + EnvironmentId, + GitActionProgressEvent, + GitResolvePullRequestResult, + GitStackedAction, + SourceControlCloneProtocol, + SourceControlRepositoryVisibility, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { gitEnvironment } from "./git"; +import { useEnvironmentQuery } from "./query"; +import { sourceControlEnvironment } from "./sourceControl"; +import { useAtomCommand } from "./use-atom-command"; +import { vcsActionManager, vcsEnvironment } from "./vcs"; + +export type SourceControlActionKind = + | "init" + | "pull" + | "publishRepository" + | "runStackedAction" + | "preparePullRequestThread"; + +export interface SourceControlActionScope { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} + +interface SourceControlActionState< + TArgs extends ReadonlyArray, + R extends AtomCommandResult, +> { + readonly isPending: boolean; + readonly error: unknown; + readonly run: ( + ...args: TArgs + ) => Promise< + AtomCommandResult, AtomCommandFailure | VcsActionUnavailableError> + >; + readonly resetError: () => void; +} + +const ACTION_OPERATION = { + init: "init", + pull: "pull", + publishRepository: "publish_repository", + runStackedAction: "run_change_request", + preparePullRequestThread: "prepare_pull_request_thread", +} as const satisfies Record; + +function useAction< + TArgs extends ReadonlyArray, + R extends AtomCommandResult, +>(input: { + readonly kind: SourceControlActionKind; + readonly label: string; + readonly scope: SourceControlActionScope; + readonly action: (...args: TArgs) => Promise; + readonly onSuccess?: () => void; + readonly managedExternally?: boolean; +}): SourceControlActionState { + const operation = ACTION_OPERATION[input.kind]; + const state = useAtomValue(vcsActionManager.stateAtom(input.scope)); + const ownsState = state.operation === operation; + + const resetError = useCallback(() => { + vcsActionManager.resetError(appAtomRegistry, input.scope, operation); + }, [input.scope, operation]); + + const run = useCallback( + async (...args: TArgs) => { + const execute = async (): Promise< + AtomCommandResult, AtomCommandFailure> + > => { + const result = await input.action(...args); + if (AsyncResult.isSuccess(result)) { + input.onSuccess?.(); + } + return result as AtomCommandResult, AtomCommandFailure>; + }; + return input.managedExternally === true + ? execute() + : vcsActionManager.track( + appAtomRegistry, + input.scope, + { + operation, + label: input.label, + }, + execute, + ); + }, + [input.action, input.label, input.managedExternally, input.onSuccess, input.scope, operation], + ); + + return { + error: ownsState ? state.error : null, + isPending: ownsState && state.isRunning, + resetError, + run, + }; +} + +function resolveScope(scope: SourceControlActionScope) { + if (scope.environmentId === null || scope.cwd === null) { + return null; + } + return { + environmentId: scope.environmentId, + cwd: scope.cwd, + }; +} + +export function useSourceControlActionRunning( + scope: SourceControlActionScope, + kinds: ReadonlyArray, +): boolean { + const state = useAtomValue(vcsActionManager.stateAtom(scope)); + return ( + state.isRunning && + state.operation !== null && + kinds.some((kind) => ACTION_OPERATION[kind] === state.operation) + ); +} + +export function useVcsInitAction(scope: SourceControlActionScope) { + const init = useAtomCommand(vcsEnvironment.init, { reportFailure: false }); + const action = useCallback(async () => { + const target = resolveScope(scope); + if (target === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "init", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return init({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }); + }, [init, scope]); + return useAction({ kind: "init", label: "Initializing repository", scope, action }); +} + +export function useVcsPullAction(scope: SourceControlActionScope) { + const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback(async () => { + const target = resolveScope(scope); + if (target === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "pull", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return pull({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }); + }, [pull, scope]); + return useAction({ + kind: "pull", + label: "Pulling latest changes", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function useGitStackedAction(scope: SourceControlActionScope) { + const runStackedAction = useAtomCommand(vcsActionManager.runStackedAction(scope), { + reportFailure: false, + }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + + const action = useCallback( + async (input: { + actionId: string; + action: GitStackedAction; + commitMessage?: string; + featureBranch?: boolean; + filePaths?: string[]; + onProgress?: (event: GitActionProgressEvent) => void; + }) => { + if (resolveScope(scope) === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "run_change_request", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return runStackedAction({ + actionId: input.actionId, + action: input.action, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch ? { featureBranch: true } : {}), + ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}), + ...(input.onProgress ? { onProgress: input.onProgress } : {}), + }); + }, + [runStackedAction, scope], + ); + + return useAction({ + kind: "runStackedAction", + label: "Running source control action", + scope, + action, + onSuccess: status.refresh, + managedExternally: true, + }); +} + +export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback( + async (input: { + provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; + repository: string; + visibility: SourceControlRepositoryVisibility; + remoteName: string; + protocol: SourceControlCloneProtocol; + }) => { + const target = resolveScope(scope); + if (target === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "publish_repository", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return publishRepository({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + ...input, + }, + }); + }, + [publishRepository, scope], + ); + return useAction({ + kind: "publishRepository", + label: "Publishing repository", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { + const preparePullRequestThread = useAtomCommand(gitEnvironment.preparePullRequestThread, { + reportFailure: false, + }); + const action = useCallback( + async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { + const target = resolveScope(scope); + if (target === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "prepare_pull_request_thread", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return preparePullRequestThread({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + reference: input.reference, + mode: input.mode, + ...(input.threadId ? { threadId: input.threadId } : {}), + }, + }); + }, + [preparePullRequestThread, scope], + ); + return useAction({ + kind: "preparePullRequestThread", + label: "Preparing pull request thread", + scope, + action, + }); +} + +export interface PullRequestResolutionTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly reference: string | null; +} + +export function readCachedPullRequestResolution( + target: PullRequestResolutionTarget, +): GitResolvePullRequestResult | null { + if (target.environmentId === null || target.cwd === null || target.reference === null) { + return null; + } + return Option.getOrNull( + AsyncResult.value( + appAtomRegistry.get( + gitEnvironment.pullRequestResolution({ + environmentId: target.environmentId, + input: { cwd: target.cwd, reference: target.reference }, + }), + ), + ), + ); +} + +export function usePullRequestResolutionState(target: PullRequestResolutionTarget) { + const query = useEnvironmentQuery( + target.environmentId !== null && target.cwd !== null && target.reference !== null + ? gitEnvironment.pullRequestResolution({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + reference: target.reference, + }, + }) + : null, + ); + const cached = readCachedPullRequestResolution(target); + + return { + data: query.data ?? cached, + error: query.error, + isPending: query.isPending && cached === null, + isFetching: query.isPending, + refresh: query.refresh, + }; +} diff --git a/apps/web/src/state/terminal.ts b/apps/web/src/state/terminal.ts new file mode 100644 index 00000000000..920267c33d5 --- /dev/null +++ b/apps/web/src/state/terminal.ts @@ -0,0 +1,5 @@ +import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts new file mode 100644 index 00000000000..9e480df08b2 --- /dev/null +++ b/apps/web/src/state/terminalSessions.ts @@ -0,0 +1,90 @@ +import { + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, + EMPTY_TERMINAL_SESSION_STATE, + selectRunningSubprocessTerminalIds, + type KnownTerminalSession, + type TerminalSessionState, +} from "@t3tools/client-runtime/state/terminal"; +import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { useEnvironmentQuery } from "./query"; +import { terminalEnvironment } from "./terminal"; + +export function useAttachedTerminalSession(input: { + readonly environmentId: EnvironmentId | null; + readonly terminal: TerminalAttachInput | null; +}): TerminalSessionState { + const attach = useEnvironmentQuery( + input.environmentId !== null && input.terminal !== null + ? terminalEnvironment.attach({ + environmentId: input.environmentId, + input: input.terminal, + }) + : null, + ); + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), + ); + + return useMemo(() => { + if (input.environmentId === null || input.terminal === null) { + return EMPTY_TERMINAL_SESSION_STATE; + } + const summary = + metadata.data?.find( + (terminal) => + terminal.threadId === input.terminal?.threadId && + terminal.terminalId === input.terminal?.terminalId, + ) ?? null; + const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); + return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; + }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); +} + +export function useKnownTerminalSessions(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray { + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), + ); + return useMemo(() => { + if (input.environmentId === null) { + return []; + } + return (metadata.data ?? []) + .filter((summary) => input.threadId === null || summary.threadId === input.threadId) + .map((summary) => ({ + target: { + environmentId: input.environmentId!, + threadId: ThreadId.make(summary.threadId), + terminalId: summary.terminalId, + }, + state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), + })) + .sort((left, right) => + left.target.terminalId.localeCompare(right.target.terminalId, undefined, { + numeric: true, + }), + ); + }, [input.environmentId, input.threadId, metadata.data]); +} + +export function useThreadRunningTerminalIds(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray { + return selectRunningSubprocessTerminalIds(useKnownTerminalSessions(input)); +} diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts new file mode 100644 index 00000000000..fd936f99ff2 --- /dev/null +++ b/apps/web/src/state/threads.ts @@ -0,0 +1,45 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createEnvironmentThreadDetailAtoms, + createEnvironmentThreadShellAtoms, + createEnvironmentThreadStateAtoms, + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, + createThreadEnvironmentAtoms, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); +export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( + environmentThreads.stateAtom, +); +export const environmentThreadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); + +const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( + Atom.withLabel("web-environment-thread:empty"), +); + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + const result = useAtomValue( + environmentId !== null && threadId !== null + ? environmentThreads.stateAtom(environmentId, threadId) + : EMPTY_THREAD_STATE_ATOM, + ); + return Option.getOrElse( + AsyncResult.value(result), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ) as EnvironmentThreadState; +} diff --git a/apps/web/src/state/use-atom-command.ts b/apps/web/src/state/use-atom-command.ts new file mode 100644 index 00000000000..37ce280e9f4 --- /dev/null +++ b/apps/web/src/state/use-atom-command.ts @@ -0,0 +1,23 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + type AtomCommand, + type AtomCommandOptions, + type AtomCommandResult, + runAtomCommand, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useContext } from "react"; + +export function useAtomCommand( + command: AtomCommand, + options?: string | AtomCommandOptions, +): (value: W) => Promise> { + const registry = useContext(RegistryContext); + const label = typeof options === "string" ? options : (options?.label ?? command.label); + const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); + const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + + return useCallback( + (value: W) => runAtomCommand(registry, command, value, { label, reportFailure, reportDefect }), + [command, label, registry, reportDefect, reportFailure], + ); +} diff --git a/apps/web/src/state/use-atom-query-runner.ts b/apps/web/src/state/use-atom-query-runner.ts new file mode 100644 index 00000000000..22f971e09a5 --- /dev/null +++ b/apps/web/src/state/use-atom-query-runner.ts @@ -0,0 +1,30 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + executeAtomQuery, + type AtomCommandOptions, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult, type Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext } from "react"; + +export function useAtomQueryRunner( + family: (target: T) => Atom.Atom>, + options?: string | AtomCommandOptions, +): (target: T) => Promise> { + const registry = useContext(RegistryContext); + const explicitLabel = typeof options === "string" ? options : options?.label; + const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); + const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + + return useCallback( + (target: T) => { + const atom = family(target); + return executeAtomQuery(registry, atom, { + label: explicitLabel ?? atom.label?.[0] ?? "atom query", + reportFailure, + reportDefect, + }); + }, + [explicitLabel, family, registry, reportDefect, reportFailure], + ); +} diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts new file mode 100644 index 00000000000..dc8c251149f --- /dev/null +++ b/apps/web/src/state/vcs.ts @@ -0,0 +1,9 @@ +import { + createVcsActionManager, + createVcsEnvironmentAtoms, +} from "@t3tools/client-runtime/state/vcs"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); +export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts deleted file mode 100644 index 1fb6115ffcc..00000000000 --- a/apps/web/src/store.test.ts +++ /dev/null @@ -1,1085 +0,0 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { - CheckpointRef, - DEFAULT_MODEL, - EnvironmentId, - EventId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationEvent, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - applyOrchestrationEvent, - applyOrchestrationEvents, - removeEnvironmentState, - selectEnvironmentState, - selectProjectsAcrossEnvironments, - selectThreadByRef, - selectThreadExistsByRef, - setThreadBranch, - selectThreadsAcrossEnvironments, - type AppState, - type EnvironmentState, -} from "./store"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; - -const localEnvironmentId = EnvironmentId.make("environment-local"); -const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - -function withActiveEnvironmentState( - environmentState: EnvironmentState, - overrides: Partial = {}, -): AppState { - const { - activeEnvironmentId: overrideActiveEnvironmentId, - environmentStateById: overrideEnvironmentStateById, - ...environmentOverrides - } = overrides; - const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; - const mergedEnvironmentState = { - ...environmentState, - ...environmentOverrides, - }; - const environmentStateById = - overrideEnvironmentStateById ?? - (activeEnvironmentId - ? { - [activeEnvironmentId]: mergedEnvironmentState, - } - : {}); - - return { - activeEnvironmentId, - environmentStateById, - }; -} - -function makeThread(overrides: Partial = {}): Thread { - return { - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - messages: [], - turnDiffSummaries: [], - activities: [], - proposedPlans: [], - error: null, - createdAt: "2026-02-13T00:00:00.000Z", - archivedAt: null, - latestTurn: null, - branch: null, - worktreePath: null, - goal: null, - ...overrides, - }; -} - -function makeState(thread: Thread): AppState { - const projectId = ProjectId.make("project-1"); - const project = { - id: projectId, - environmentId: thread.environmentId, - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt: "2026-02-13T00:00:00.000Z", - updatedAt: "2026-02-13T00:00:00.000Z", - scripts: [], - }; - const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { - [thread.projectId]: [thread.id], - }; - const environmentState = { - projectIds: [projectId], - projectById: { - [projectId]: project, - }, - threadIds: [thread.id], - threadIdsByProjectId, - threadShellById: { - [thread.id]: { - id: thread.id, - environmentId: thread.environmentId, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: thread.error, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - goal: thread.goal, - }, - }, - threadSessionById: { - [thread.id]: thread.session, - }, - threadTurnStateById: { - [thread.id]: { - latestTurn: thread.latestTurn, - ...(thread.pendingSourceProposedPlan - ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } - : {}), - }, - }, - messageIdsByThreadId: { - [thread.id]: thread.messages.map((message) => message.id), - }, - messageByThreadId: { - [thread.id]: Object.fromEntries( - thread.messages.map((message) => [message.id, message] as const), - ) as EnvironmentState["messageByThreadId"][ThreadId], - }, - activityIdsByThreadId: { - [thread.id]: thread.activities.map((activity) => activity.id), - }, - activityByThreadId: { - [thread.id]: Object.fromEntries( - thread.activities.map((activity) => [activity.id, activity] as const), - ) as EnvironmentState["activityByThreadId"][ThreadId], - }, - proposedPlanIdsByThreadId: { - [thread.id]: thread.proposedPlans.map((plan) => plan.id), - }, - proposedPlanByThreadId: { - [thread.id]: Object.fromEntries( - thread.proposedPlans.map((plan) => [plan.id, plan] as const), - ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], - }, - turnDiffIdsByThreadId: { - [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), - }, - turnDiffSummaryByThreadId: { - [thread.id]: Object.fromEntries( - thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), - ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId], - }, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; - return withActiveEnvironmentState(environmentState, { - activeEnvironmentId: thread.environmentId, - }); -} - -function makeEmptyState(overrides: Partial = {}): AppState { - const environmentState: EnvironmentState = { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; - return withActiveEnvironmentState(environmentState, overrides); -} - -function localEnvironmentStateOf(state: AppState): EnvironmentState { - return selectEnvironmentState(state, localEnvironmentId); -} - -function environmentStateOf(state: AppState, environmentId: EnvironmentId): EnvironmentState { - return selectEnvironmentState(state, environmentId); -} - -function projectsOf(state: AppState) { - return selectProjectsAcrossEnvironments(state); -} - -function threadsOf(state: AppState) { - return selectThreadsAcrossEnvironments(state); -} - -function makeEvent( - type: T, - payload: Extract["payload"], - overrides: Partial> = {}, -): Extract { - const sequence = overrides.sequence ?? 1; - return { - sequence, - eventId: EventId.make(`event-${sequence}`), - aggregateKind: "thread", - aggregateId: - "threadId" in payload - ? payload.threadId - : "projectId" in payload - ? payload.projectId - : ProjectId.make("project-1"), - occurredAt: "2026-02-27T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type, - payload, - ...overrides, - } as Extract; -} - -describe("environment state removal", () => { - it("drops local state for removed environments", () => { - const removedThread = makeThread({ - environmentId: remoteEnvironmentId, - id: ThreadId.make("thread-removed"), - }); - const keptThread = makeThread({ id: ThreadId.make("thread-kept") }); - const removedState = makeState(removedThread).environmentStateById[remoteEnvironmentId]!; - const keptState = makeState(keptThread).environmentStateById[localEnvironmentId]!; - const state: AppState = { - activeEnvironmentId: remoteEnvironmentId, - environmentStateById: { - [remoteEnvironmentId]: removedState, - [localEnvironmentId]: keptState, - }, - }; - - const next = removeEnvironmentState(state, remoteEnvironmentId); - - expect(next.activeEnvironmentId).toBeNull(); - expect(next.environmentStateById[remoteEnvironmentId]).toBeUndefined(); - expect(next.environmentStateById[localEnvironmentId]).toBe(keptState); - }); - - it("preserves active environment when removing a different environment", () => { - const state = makeState(makeThread()); - - const next = removeEnvironmentState(state, remoteEnvironmentId); - - expect(next).toBe(state); - }); -}); - -describe("thread selection memoization", () => { - it("returns stable thread references for repeated reads of the same state", () => { - const thread = makeThread({ - messages: [ - { - id: MessageId.make("message-1"), - role: "user", - text: "hello", - createdAt: "2026-02-13T00:01:00.000Z", - streaming: false, - }, - ], - activities: [ - { - id: EventId.make("activity-1"), - tone: "info", - kind: "step", - summary: "working", - payload: {}, - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-13T00:01:30.000Z", - }, - ], - proposedPlans: [ - { - id: "plan-1", - turnId: null, - planMarkdown: "plan", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-13T00:02:00.000Z", - updatedAt: "2026-02-13T00:02:00.000Z", - }, - ], - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-13T00:03:00.000Z", - files: [], - }, - ], - }); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - - const first = selectThreadByRef(state, ref); - const second = selectThreadByRef(state, ref); - - expect(first).toBeDefined(); - expect(second).toBe(first); - expect(second?.messages).toBe(first?.messages); - expect(second?.activities).toBe(first?.activities); - expect(second?.proposedPlans).toBe(first?.proposedPlans); - expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries); - }); - - it("reuses the derived thread when the app state wrapper changes but thread data does not", () => { - const thread = makeThread({ - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "done", - createdAt: "2026-02-13T00:01:00.000Z", - streaming: false, - }, - ], - }); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - const wrappedState: AppState = { - ...state, - environmentStateById: { ...state.environmentStateById }, - }; - - const first = selectThreadByRef(state, ref); - const second = selectThreadByRef(wrappedState, ref); - - expect(second).toBe(first); - }); - - it("updates the derived thread when the underlying thread data changes", () => { - const thread = makeThread(); - const ref = scopeThreadRef(thread.environmentId, thread.id); - const firstState = makeState(thread); - const secondState = makeState({ - ...thread, - messages: [ - { - id: MessageId.make("message-2"), - role: "user", - text: "new", - createdAt: "2026-02-13T00:04:00.000Z", - streaming: false, - }, - ], - }); - - const first = selectThreadByRef(firstState, ref); - const second = selectThreadByRef(secondState, ref); - - expect(second).not.toBe(first); - expect(second?.messages).toHaveLength(1); - expect(second?.messages[0]?.text).toBe("new"); - }); - - it("checks thread existence without materializing the full thread", () => { - const thread = makeThread(); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - - expect(selectThreadExistsByRef(state, ref)).toBe(true); - expect( - selectThreadExistsByRef( - state, - scopeThreadRef(thread.environmentId, ThreadId.make("missing")), - ), - ).toBe(false); - expect(selectThreadExistsByRef(state, null)).toBe(false); - }); -}); - -describe("setThreadBranch", () => { - it("updates only the scoped thread environment", () => { - const sharedThreadId = ThreadId.make("thread-shared"); - const localThread = makeThread({ - id: sharedThreadId, - environmentId: localEnvironmentId, - branch: "local-branch", - }); - const remoteThread = makeThread({ - id: sharedThreadId, - environmentId: remoteEnvironmentId, - branch: "remote-branch", - }); - const state: AppState = { - activeEnvironmentId: localEnvironmentId, - environmentStateById: { - [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId), - [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId), - }, - }; - - const next = setThreadBranch( - state, - scopeThreadRef(remoteEnvironmentId, sharedThreadId), - "remote-next", - "/tmp/remote-worktree", - ); - - expect( - environmentStateOf(next, localEnvironmentId).threadShellById[sharedThreadId]?.branch, - ).toBe("local-branch"); - expect( - environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.branch, - ).toBe("remote-next"); - expect( - environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath, - ).toBe("/tmp/remote-worktree"); - }); -}); - -describe("incremental orchestration updates", () => { - it("does not mark bootstrap complete for incremental events", () => { - const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { - bootstrapComplete: false, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.meta-updated", { - threadId: ThreadId.make("thread-1"), - title: "Updated title", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false); - }); - - it("preserves state identity for no-op project and thread deletes", () => { - const thread = makeThread(); - const state = makeState(thread); - - const nextAfterProjectDelete = applyOrchestrationEvent( - state, - makeEvent("project.deleted", { - projectId: ProjectId.make("project-missing"), - deletedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - const nextAfterThreadDelete = applyOrchestrationEvent( - state, - makeEvent("thread.deleted", { - threadId: ThreadId.make("thread-missing"), - deletedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(nextAfterProjectDelete).toBe(state); - expect(nextAfterThreadDelete).toBe(state); - }); - - it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { - const originalProjectId = ProjectId.make("project-1"); - const recreatedProjectId = ProjectId.make("project-2"); - const state: AppState = makeEmptyState({ - projectIds: [originalProjectId], - projectById: { - [originalProjectId]: { - id: originalProjectId, - environmentId: localEnvironmentId, - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("project.created", { - projectId: recreatedProjectId, - title: "Project Recreated", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - scripts: [], - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(projectsOf(next)).toHaveLength(1); - expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); - expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); - expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); - expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]); - expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined(); - expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe( - recreatedProjectId, - ); - }); - - it("removes stale project index entries when thread.created recreates a thread under a new project", () => { - const originalProjectId = ProjectId.make("project-1"); - const recreatedProjectId = ProjectId.make("project-2"); - const threadId = ThreadId.make("thread-1"); - const thread = makeThread({ - id: threadId, - projectId: originalProjectId, - }); - const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), { - projectIds: [originalProjectId, recreatedProjectId], - projectById: { - [originalProjectId]: { - id: originalProjectId, - environmentId: localEnvironmentId, - name: "Project 1", - cwd: "/tmp/project-1", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - [recreatedProjectId]: { - id: recreatedProjectId, - environmentId: localEnvironmentId, - name: "Project 2", - cwd: "/tmp/project-2", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.created", { - threadId, - projectId: recreatedProjectId, - title: "Recovered thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - branch: null, - worktreePath: null, - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)).toHaveLength(1); - expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); - expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined(); - expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([ - threadId, - ]); - }); - - it("updates only the affected thread for message events", () => { - const thread1 = makeThread({ - id: ThreadId.make("thread-1"), - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "hello", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:00.000Z", - streaming: false, - }, - ], - }); - const thread2 = makeThread({ id: ThreadId.make("thread-2") }); - const baseState = makeState(thread1); - const baseEnvironmentState = localEnvironmentStateOf(baseState); - const state = withActiveEnvironmentState(baseEnvironmentState, { - threadIds: [thread1.id, thread2.id], - threadShellById: { - ...baseEnvironmentState.threadShellById, - [thread2.id]: { - id: thread2.id, - environmentId: thread2.environmentId, - codexThreadId: thread2.codexThreadId, - projectId: thread2.projectId, - title: thread2.title, - modelSelection: thread2.modelSelection, - runtimeMode: thread2.runtimeMode, - interactionMode: thread2.interactionMode, - error: thread2.error, - createdAt: thread2.createdAt, - archivedAt: thread2.archivedAt, - updatedAt: thread2.updatedAt, - branch: thread2.branch, - worktreePath: thread2.worktreePath, - }, - }, - threadSessionById: { - ...baseEnvironmentState.threadSessionById, - [thread2.id]: thread2.session, - }, - threadTurnStateById: { - ...baseEnvironmentState.threadTurnStateById, - [thread2.id]: { - latestTurn: thread2.latestTurn, - }, - }, - messageIdsByThreadId: { - ...baseEnvironmentState.messageIdsByThreadId, - [thread2.id]: [], - }, - messageByThreadId: { - ...baseEnvironmentState.messageByThreadId, - [thread2.id]: {}, - }, - activityIdsByThreadId: { - ...baseEnvironmentState.activityIdsByThreadId, - [thread2.id]: [], - }, - activityByThreadId: { - ...baseEnvironmentState.activityByThreadId, - [thread2.id]: {}, - }, - proposedPlanIdsByThreadId: { - ...baseEnvironmentState.proposedPlanIdsByThreadId, - [thread2.id]: [], - }, - proposedPlanByThreadId: { - ...baseEnvironmentState.proposedPlanByThreadId, - [thread2.id]: {}, - }, - turnDiffIdsByThreadId: { - ...baseEnvironmentState.turnDiffIdsByThreadId, - [thread2.id]: [], - }, - turnDiffSummaryByThreadId: { - ...baseEnvironmentState.turnDiffSummaryByThreadId, - [thread2.id]: {}, - }, - sidebarThreadSummaryById: { - ...baseEnvironmentState.sidebarThreadSummaryById, - }, - threadIdsByProjectId: { - [thread1.projectId]: [thread1.id, thread2.id], - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.message-sent", { - threadId: thread1.id, - messageId: MessageId.make("message-1"), - role: "assistant", - text: " world", - turnId: TurnId.make("turn-1"), - streaming: true, - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); - expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - const nextEnvironmentState = next.environmentStateById[localEnvironmentId]; - const previousEnvironmentState = state.environmentStateById[localEnvironmentId]; - expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe( - previousEnvironmentState?.threadShellById[thread2.id], - ); - expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe( - previousEnvironmentState?.threadSessionById[thread2.id], - ); - expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe( - previousEnvironmentState?.messageIdsByThreadId[thread2.id], - ); - expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe( - previousEnvironmentState?.messageByThreadId[thread2.id], - ); - }); - - it("applies replay batches in sequence and updates session state", () => { - const thread = makeThread({ - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "running", - requestedAt: "2026-02-27T00:00:00.000Z", - startedAt: "2026-02-27T00:00:00.000Z", - completedAt: null, - assistantMessageId: null, - }, - }); - const state = makeState(thread); - - const next = applyOrchestrationEvents( - state, - [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { - threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", - }, - }, - { sequence: 2 }, - ), - makeEvent( - "thread.message-sent", - { - threadId: thread.id, - messageId: MessageId.make("assistant-1"), - role: "assistant", - text: "done", - turnId: TurnId.make("turn-1"), - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }, - { sequence: 3 }, - ), - ], - localEnvironmentId, - ); - - // A completed assistant message must not settle the turn while the - // session is still running it — providers emit interim assistant - // messages between tool calls. - expect(threadsOf(next)[0]?.session?.status).toBe("running"); - expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - expect(threadsOf(next)[0]?.latestTurn?.completedAt).toBeNull(); - expect(threadsOf(next)[0]?.messages).toHaveLength(1); - - const settled = applyOrchestrationEvents( - next, - [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { - threadId: thread.id, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-02-27T00:00:04.000Z", - }, - }, - { sequence: 4 }, - ), - ], - localEnvironmentId, - ); - - // Leaving the running session status is the turn-end signal. - expect(threadsOf(settled)[0]?.latestTurn?.state).toBe("completed"); - expect(threadsOf(settled)[0]?.latestTurn?.completedAt).toBe("2026-02-27T00:00:04.000Z"); - }); - - it("does not regress latestTurn when an older turn diff completes late", () => { - const state = makeState( - makeThread({ - latestTurn: { - turnId: TurnId.make("turn-2"), - state: "running", - requestedAt: "2026-02-27T00:00:02.000Z", - startedAt: "2026-02-27T00:00:03.000Z", - completedAt: null, - assistantMessageId: null, - }, - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.turn-diff-completed", { - threadId: ThreadId.make("thread-1"), - turnId: TurnId.make("turn-1"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-1"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("assistant-1"), - completedAt: "2026-02-27T00:00:04.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); - expect(threadsOf(next)[0]?.latestTurn).toEqual(threadsOf(state)[0]?.latestTurn); - }); - - it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { - const turnId = TurnId.make("turn-1"); - const state = makeState( - makeThread({ - latestTurn: { - turnId, - state: "completed", - requestedAt: "2026-02-27T00:00:00.000Z", - startedAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:02.000Z", - assistantMessageId: MessageId.make("assistant:turn-1"), - }, - turnDiffSummaries: [ - { - turnId, - completedAt: "2026-02-27T00:00:02.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-1"), - assistantMessageId: MessageId.make("assistant:turn-1"), - files: [{ path: "src/app.ts", additions: 1, deletions: 0 }], - }, - ], - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.message-sent", { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("assistant-real"), - role: "assistant", - text: "final answer", - turnId, - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( - MessageId.make("assistant-real"), - ); - expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe( - MessageId.make("assistant-real"), - ); - }); - - it("reverts messages, plans, activities, and checkpoints by retained turns", () => { - const state = makeState( - makeThread({ - messages: [ - { - id: MessageId.make("user-1"), - role: "user", - text: "first", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:00.000Z", - streaming: false, - }, - { - id: MessageId.make("assistant-1"), - role: "assistant", - text: "first reply", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:01.000Z", - completedAt: "2026-02-27T00:00:01.000Z", - streaming: false, - }, - { - id: MessageId.make("user-2"), - role: "user", - text: "second", - turnId: TurnId.make("turn-2"), - createdAt: "2026-02-27T00:00:02.000Z", - completedAt: "2026-02-27T00:00:02.000Z", - streaming: false, - }, - ], - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-1"), - planMarkdown: "plan 1", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - }, - { - id: "plan-2", - turnId: TurnId.make("turn-2"), - planMarkdown: "plan 2", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-27T00:00:02.000Z", - updatedAt: "2026-02-27T00:00:02.000Z", - }, - ], - activities: [ - { - id: EventId.make("activity-1"), - tone: "info", - kind: "step", - summary: "one", - payload: {}, - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - }, - { - id: EventId.make("activity-2"), - tone: "info", - kind: "step", - summary: "two", - payload: {}, - turnId: TurnId.make("turn-2"), - createdAt: "2026-02-27T00:00:02.000Z", - }, - ], - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-27T00:00:01.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - files: [], - }, - { - turnId: TurnId.make("turn-2"), - completedAt: "2026-02-27T00:00:03.000Z", - status: "ready", - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.make("ref-2"), - files: [], - }, - ], - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.reverted", { - threadId: ThreadId.make("thread-1"), - turnCount: 1, - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ - "user-1", - "assistant-1", - ]); - expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); - expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ - EventId.make("activity-1"), - ]); - expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ - TurnId.make("turn-1"), - ]); - }); - - it("clears pending source proposed plans after revert before a new session-set event", () => { - const thread = makeThread({ - latestTurn: { - turnId: TurnId.make("turn-2"), - state: "completed", - requestedAt: "2026-02-27T00:00:02.000Z", - startedAt: "2026-02-27T00:00:02.000Z", - completedAt: "2026-02-27T00:00:03.000Z", - assistantMessageId: MessageId.make("assistant-2"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-source"), - planId: "plan-2" as never, - }, - }, - pendingSourceProposedPlan: { - threadId: ThreadId.make("thread-source"), - planId: "plan-2" as never, - }, - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-27T00:00:01.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - files: [], - }, - { - turnId: TurnId.make("turn-2"), - completedAt: "2026-02-27T00:00:03.000Z", - status: "ready", - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.make("ref-2"), - files: [], - }, - ], - }); - const reverted = applyOrchestrationEvent( - makeState(thread), - makeEvent("thread.reverted", { - threadId: thread.id, - turnCount: 1, - }), - localEnvironmentId, - ); - - expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); - - const next = applyOrchestrationEvent( - reverted, - makeEvent("thread.session-set", { - threadId: thread.id, - session: { - threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-3"), - lastError: null, - updatedAt: "2026-02-27T00:00:04.000Z", - }, - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ - turnId: TurnId.make("turn-3"), - state: "running", - }); - expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); - }); -}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts deleted file mode 100644 index 21df17fa22a..00000000000 --- a/apps/web/src/store.ts +++ /dev/null @@ -1,2088 +0,0 @@ -import type { - EnvironmentId, - MessageId, - OrchestrationCheckpointSummary, - OrchestrationEvent, - OrchestrationLatestTurn, - OrchestrationMessage, - OrchestrationProposedPlan, - OrchestrationReadModel, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, - OrchestrationSession, - OrchestrationSessionStatus, - OrchestrationThread, - OrchestrationThreadShell, - OrchestrationThreadActivity, - ProjectId, - ScopedProjectRef, - ScopedThreadRef, -} from "@t3tools/contracts"; -import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts"; -import type { ThreadId, TurnId } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { resolveModelSlugForProvider } from "@t3tools/shared/model"; -import { create } from "zustand"; -import { - type ChatMessage, - type Project, - type ProposedPlan, - type SidebarThreadSummary, - type Thread, - type ThreadSession, - type ThreadShell, - type ThreadTurnState, - type TurnDiffSummary, -} from "./types"; -import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -import { getThreadFromEnvironmentState } from "./threadDerivation"; -const isProviderDriverKindValue = Schema.is(ProviderDriverKind); - -export interface EnvironmentState { - projectIds: ProjectId[]; - projectById: Record; - - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Web still stores shell snapshots and thread details in this denormalized - // Zustand shape. Mobile uses createShellSnapshotManager and - // createThreadDetailManager from @t3tools/client-runtime. New shared behavior - // belongs in those managers/reducers, with a web adapter layered on top. - // - // --------------------------------------------------------------------------- - // Thread bookkeeping — written by BOTH shell stream and detail stream. - // Both streams ensure the thread is registered here; the bookkeeping is - // additive (append-only IDs) so concurrent writes are safe. - // --------------------------------------------------------------------------- - threadIds: ThreadId[]; - threadIdsByProjectId: Record; - - // --------------------------------------------------------------------------- - // Thread shell / session / turn — written by BOTH shell stream and detail - // stream. The shell stream is the *authoritative* source (server pre- - // computes these from the projection pipeline), but the detail stream also - // writes them so the active thread has up-to-date state even if the shell - // event hasn't arrived yet. Structural equality checks in both write - // functions prevent unnecessary React re-renders when both streams deliver - // equivalent data. - // --------------------------------------------------------------------------- - threadShellById: Record; - threadSessionById: Record; - threadTurnStateById: Record; - - // --------------------------------------------------------------------------- - // Thread detail content — written ONLY by the detail stream - // (writeThreadState / syncServerThreadDetail). The shell stream never - // touches these. - // --------------------------------------------------------------------------- - messageIdsByThreadId: Record; - messageByThreadId: Record>; - activityIdsByThreadId: Record; - activityByThreadId: Record>; - proposedPlanIdsByThreadId: Record; - proposedPlanByThreadId: Record>; - turnDiffIdsByThreadId: Record; - turnDiffSummaryByThreadId: Record>; - - // --------------------------------------------------------------------------- - // Sidebar summary — written ONLY by the shell stream - // (writeThreadShellState / mapThreadShell). Pre-computed server-side with - // fields like latestUserMessageAt, hasPendingApprovals, etc. The detail - // stream must NOT write here; the shell stream is the single source of - // truth for sidebar data. - // --------------------------------------------------------------------------- - sidebarThreadSummaryById: Record; - - bootstrapComplete: boolean; -} - -export interface AppState { - activeEnvironmentId: EnvironmentId | null; - environmentStateById: Record; -} - -const initialEnvironmentState: EnvironmentState = { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, -}; - -const initialState: AppState = { - activeEnvironmentId: null, - environmentStateById: {}, -}; - -const MAX_THREAD_MESSAGES = 2_000; -const MAX_THREAD_CHECKPOINTS = 500; -const MAX_THREAD_PROPOSED_PLANS = 200; -const MAX_THREAD_ACTIVITIES = 500; -const EMPTY_THREAD_IDS: ThreadId[] = []; - -function arraysEqual(left: readonly T[], right: readonly T[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -// Accepts the open `instanceId` string carried on `ModelSelection`; malformed -// values pass through unchanged, while valid slugs use any registered alias -// table for model normalization. -function normalizeModelSelection(selection: T): T { - if (!isProviderDriverKind(selection.instanceId)) { - return selection; - } - return { - ...selection, - model: resolveModelSlugForProvider(selection.instanceId, selection.model), - }; -} - -function mapProjectScripts(scripts: ReadonlyArray): Project["scripts"] { - return scripts.map((script) => ({ ...script })); -} - -function mapSession(session: OrchestrationSession): ThreadSession { - return { - provider: toLegacyProvider(session.providerName), - providerInstanceId: session.providerInstanceId ?? undefined, - status: toLegacySessionStatus(session.status), - orchestrationStatus: session.status, - activeTurnId: session.activeTurnId ?? undefined, - createdAt: session.updatedAt, - updatedAt: session.updatedAt, - ...(session.lastError ? { lastError: session.lastError } : {}), - }; -} - -function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage): ChatMessage { - const attachments = message.attachments?.map((attachment) => ({ - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - })); - - return { - id: message.id, - role: message.role, - text: message.text, - turnId: message.turnId, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; -} - -function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan { - return { - id: proposedPlan.id, - turnId: proposedPlan.turnId, - planMarkdown: proposedPlan.planMarkdown, - implementedAt: proposedPlan.implementedAt, - implementationThreadId: proposedPlan.implementationThreadId, - createdAt: proposedPlan.createdAt, - updatedAt: proposedPlan.updatedAt, - }; -} - -function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary { - return { - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: checkpoint.files.map((file) => ({ ...file })), - }; -} - -function mapProject( - project: - | OrchestrationReadModel["projects"][number] - | OrchestrationShellSnapshot["projects"][number], - environmentId: EnvironmentId, -): Project { - return { - id: project.id, - environmentId, - name: project.title, - cwd: project.workspaceRoot, - repositoryIdentity: project.repositoryIdentity ?? null, - defaultModelSelection: project.defaultModelSelection - ? normalizeModelSelection(project.defaultModelSelection) - : null, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - scripts: mapProjectScripts(project.scripts), - }; -} - -function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { - return { - id: thread.id, - environmentId, - codexThreadId: null, - projectId: thread.projectId, - title: thread.title, - modelSelection: normalizeModelSelection(thread.modelSelection), - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - session: thread.session ? mapSession(thread.session) : null, - messages: thread.messages.map((message) => mapMessage(environmentId, message)), - proposedPlans: thread.proposedPlans.map(mapProposedPlan), - error: sanitizeThreadErrorMessage(thread.session?.lastError), - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), - activities: thread.activities.map((activity) => ({ ...activity })), - goal: thread.goal, - }; -} - -function mapThreadShell( - thread: OrchestrationThreadShell, - environmentId: EnvironmentId, -): { - shell: ThreadShell; - session: ThreadSession | null; - turnState: ThreadTurnState; - summary: SidebarThreadSummary; -} { - const shell: ThreadShell = { - id: thread.id, - environmentId, - codexThreadId: null, - projectId: thread.projectId, - title: thread.title, - modelSelection: normalizeModelSelection(thread.modelSelection), - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: sanitizeThreadErrorMessage(thread.session?.lastError), - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - goal: thread.goal, - }; - const session = thread.session ? mapSession(thread.session) : null; - const turnState: ThreadTurnState = { - latestTurn: thread.latestTurn, - pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, - }; - const summary: SidebarThreadSummary = { - id: thread.id, - environmentId, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestUserMessageAt: thread.latestUserMessageAt, - hasPendingApprovals: thread.hasPendingApprovals, - hasPendingUserInput: thread.hasPendingUserInput, - hasActionableProposedPlan: thread.hasActionableProposedPlan, - goal: thread.goal, - }; - return { - shell, - session, - turnState, - summary, - }; -} - -function toThreadShell(thread: Thread): ThreadShell { - return { - id: thread.id, - environmentId: thread.environmentId, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: thread.error, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - goal: thread.goal, - }; -} - -function toThreadTurnState(thread: Thread): ThreadTurnState { - return { - latestTurn: thread.latestTurn, - ...(thread.pendingSourceProposedPlan - ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } - : {}), - }; -} - -function sourceProposedPlansEqual( - left: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, - right: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, -): boolean { - if (left === right) return true; - if (left === undefined || right === undefined) return false; - return left.threadId === right.threadId && left.planId === right.planId; -} - -function latestTurnsEqual( - left: OrchestrationLatestTurn | null | undefined, - right: OrchestrationLatestTurn | null | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return false; - return ( - left.turnId === right.turnId && - left.state === right.state && - left.requestedAt === right.requestedAt && - left.startedAt === right.startedAt && - left.completedAt === right.completedAt && - left.assistantMessageId === right.assistantMessageId && - sourceProposedPlansEqual(left.sourceProposedPlan, right.sourceProposedPlan) - ); -} - -function threadSessionsEqual( - left: ThreadSession | null | undefined, - right: ThreadSession | null | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return false; - return ( - left.provider === right.provider && - left.status === right.status && - left.orchestrationStatus === right.orchestrationStatus && - left.activeTurnId === right.activeTurnId && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt && - left.lastError === right.lastError - ); -} - -function threadGoalsEqual( - left: Thread["goal"] | undefined, - right: Thread["goal"] | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return false; - return ( - left.objective === right.objective && - left.status === right.status && - left.tokensUsed === right.tokensUsed && - left.tokenBudget === right.tokenBudget && - left.timeUsedSeconds === right.timeUsedSeconds && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt - ); -} - -function sidebarThreadSummariesEqual( - left: SidebarThreadSummary | undefined, - right: SidebarThreadSummary, -): boolean { - return ( - left !== undefined && - left.id === right.id && - left.projectId === right.projectId && - left.title === right.title && - left.interactionMode === right.interactionMode && - threadSessionsEqual(left.session, right.session) && - left.createdAt === right.createdAt && - left.archivedAt === right.archivedAt && - left.updatedAt === right.updatedAt && - latestTurnsEqual(left.latestTurn, right.latestTurn) && - left.branch === right.branch && - left.worktreePath === right.worktreePath && - left.latestUserMessageAt === right.latestUserMessageAt && - left.hasPendingApprovals === right.hasPendingApprovals && - left.hasPendingUserInput === right.hasPendingUserInput && - left.hasActionableProposedPlan === right.hasActionableProposedPlan && - threadGoalsEqual(left.goal, right.goal) - ); -} - -function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): boolean { - return ( - left !== undefined && - left.id === right.id && - left.environmentId === right.environmentId && - left.codexThreadId === right.codexThreadId && - left.projectId === right.projectId && - left.title === right.title && - left.modelSelection === right.modelSelection && - left.runtimeMode === right.runtimeMode && - left.interactionMode === right.interactionMode && - left.error === right.error && - left.createdAt === right.createdAt && - left.archivedAt === right.archivedAt && - left.updatedAt === right.updatedAt && - left.branch === right.branch && - left.worktreePath === right.worktreePath && - threadGoalsEqual(left.goal, right.goal) - ); -} - -function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { - return ( - left !== undefined && - latestTurnsEqual(left.latestTurn, right.latestTurn) && - sourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan) - ); -} - -function appendId(ids: readonly T[], id: T): T[] { - return ids.includes(id) ? [...ids] : [...ids, id]; -} - -function removeId(ids: readonly T[], id: T): T[] { - return ids.filter((value) => value !== id); -} - -function buildMessageSlice(thread: Thread): { - ids: MessageId[]; - byId: Record; -} { - return { - ids: thread.messages.map((message) => message.id), - byId: Object.fromEntries( - thread.messages.map((message) => [message.id, message] as const), - ) as Record, - }; -} - -function buildActivitySlice(thread: Thread): { - ids: string[]; - byId: Record; -} { - return { - ids: thread.activities.map((activity) => activity.id), - byId: Object.fromEntries( - thread.activities.map((activity) => [activity.id, activity] as const), - ) as Record, - }; -} - -function buildProposedPlanSlice(thread: Thread): { - ids: string[]; - byId: Record; -} { - return { - ids: thread.proposedPlans.map((plan) => plan.id), - byId: Object.fromEntries( - thread.proposedPlans.map((plan) => [plan.id, plan] as const), - ) as Record, - }; -} - -function buildTurnDiffSlice(thread: Thread): { - ids: TurnId[]; - byId: Record; -} { - return { - ids: thread.turnDiffSummaries.map((summary) => summary.turnId), - byId: Object.fromEntries( - thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), - ) as Record, - }; -} - -function getProjects(state: EnvironmentState): Project[] { - return state.projectIds.flatMap((projectId) => { - const project = state.projectById[projectId]; - return project ? [project] : []; - }); -} - -function getThreads(state: EnvironmentState): Thread[] { - return state.threadIds.flatMap((threadId) => { - const thread = getThreadFromEnvironmentState(state, threadId); - return thread ? [thread] : []; - }); -} - -/** - * Ensure a thread is registered in the bookkeeping indices (threadIds, - * threadIdsByProjectId). Shared by both the shell stream and detail stream - * write paths — the bookkeeping is additive (append-only IDs) so concurrent - * writes from both streams are safe. - */ -function ensureThreadRegistered( - state: EnvironmentState, - threadId: ThreadId, - nextProjectId: ProjectId, - previousProjectId: ProjectId | undefined, -): EnvironmentState { - let nextState = state; - - if (!state.threadIds.includes(threadId)) { - nextState = { - ...nextState, - threadIds: [...nextState.threadIds, threadId], - }; - } - - if (previousProjectId !== nextProjectId) { - let threadIdsByProjectId = nextState.threadIdsByProjectId; - if (previousProjectId) { - const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; - const nextIds = removeId(previousIds, threadId); - if (nextIds.length === 0) { - const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; - threadIdsByProjectId = rest as Record; - } else if (!arraysEqual(previousIds, nextIds)) { - threadIdsByProjectId = { - ...threadIdsByProjectId, - [previousProjectId]: nextIds, - }; - } - } - const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = appendId(projectThreadIds, threadId); - if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { - threadIdsByProjectId = { - ...threadIdsByProjectId, - [nextProjectId]: nextProjectThreadIds, - }; - } - if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { - nextState = { - ...nextState, - threadIdsByProjectId, - }; - } - } - - return nextState; -} - -/** - * Write thread state from the **detail stream** (per-thread subscription). - * - * Owns: messages, activities, proposed plans, turn diff summaries. - * Also writes threadShellById / threadSessionById / threadTurnStateById so - * the active thread has up-to-date state even if the shell stream event - * hasn't arrived yet (both streams use structural equality checks to avoid - * unnecessary re-renders when delivering equivalent data). - * Does NOT write sidebarThreadSummaryById — that is shell-stream-only. - */ -function writeThreadState( - state: EnvironmentState, - nextThread: Thread, - previousThread?: Thread, -): EnvironmentState { - const nextShell = toThreadShell(nextThread); - const nextTurnState = toThreadTurnState(nextThread); - const previousShell = state.threadShellById[nextThread.id]; - const previousTurnState = state.threadTurnStateById[nextThread.id]; - - let nextState = ensureThreadRegistered( - state, - nextThread.id, - nextThread.projectId, - previousThread?.projectId, - ); - - if (!threadShellsEqual(previousShell, nextShell)) { - nextState = { - ...nextState, - threadShellById: { - ...nextState.threadShellById, - [nextThread.id]: nextShell, - }, - }; - } - - if (!threadSessionsEqual(previousThread?.session ?? null, nextThread.session)) { - nextState = { - ...nextState, - threadSessionById: { - ...nextState.threadSessionById, - [nextThread.id]: nextThread.session, - }, - }; - } - - if (!threadTurnStatesEqual(previousTurnState, nextTurnState)) { - nextState = { - ...nextState, - threadTurnStateById: { - ...nextState.threadTurnStateById, - [nextThread.id]: nextTurnState, - }, - }; - } - - if (previousThread?.messages !== nextThread.messages) { - const nextMessageSlice = buildMessageSlice(nextThread); - nextState = { - ...nextState, - messageIdsByThreadId: { - ...nextState.messageIdsByThreadId, - [nextThread.id]: nextMessageSlice.ids, - }, - messageByThreadId: { - ...nextState.messageByThreadId, - [nextThread.id]: nextMessageSlice.byId, - }, - }; - } - - if (previousThread?.activities !== nextThread.activities) { - const nextActivitySlice = buildActivitySlice(nextThread); - nextState = { - ...nextState, - activityIdsByThreadId: { - ...nextState.activityIdsByThreadId, - [nextThread.id]: nextActivitySlice.ids, - }, - activityByThreadId: { - ...nextState.activityByThreadId, - [nextThread.id]: nextActivitySlice.byId, - }, - }; - } - - if (previousThread?.proposedPlans !== nextThread.proposedPlans) { - const nextProposedPlanSlice = buildProposedPlanSlice(nextThread); - nextState = { - ...nextState, - proposedPlanIdsByThreadId: { - ...nextState.proposedPlanIdsByThreadId, - [nextThread.id]: nextProposedPlanSlice.ids, - }, - proposedPlanByThreadId: { - ...nextState.proposedPlanByThreadId, - [nextThread.id]: nextProposedPlanSlice.byId, - }, - }; - } - - if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { - const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); - nextState = { - ...nextState, - turnDiffIdsByThreadId: { - ...nextState.turnDiffIdsByThreadId, - [nextThread.id]: nextTurnDiffSlice.ids, - }, - turnDiffSummaryByThreadId: { - ...nextState.turnDiffSummaryByThreadId, - [nextThread.id]: nextTurnDiffSlice.byId, - }, - }; - } - - return nextState; -} - -/** - * Write thread state from the **shell stream** (all-threads subscription). - * - * Owns: sidebarThreadSummaryById (pre-computed server-side sidebar data). - * Also writes threadShellById / threadSessionById / threadTurnStateById as - * the authoritative source for these fields. The detail stream may also - * write them for the focused thread (see writeThreadState); structural - * equality checks prevent unnecessary re-renders. - * Does NOT write message/activity/proposedPlan/turnDiff content — that is - * detail-stream-only. - */ -function writeThreadShellState( - state: EnvironmentState, - nextThread: { - shell: ThreadShell; - session: ThreadSession | null; - turnState: ThreadTurnState; - summary: SidebarThreadSummary; - }, -): EnvironmentState { - const previousShell = state.threadShellById[nextThread.shell.id]; - - let nextState = ensureThreadRegistered( - state, - nextThread.shell.id, - nextThread.shell.projectId, - previousShell?.projectId, - ); - - if (!threadShellsEqual(previousShell, nextThread.shell)) { - nextState = { - ...nextState, - threadShellById: { - ...nextState.threadShellById, - [nextThread.shell.id]: nextThread.shell, - }, - }; - } - - if ( - !threadSessionsEqual(state.threadSessionById[nextThread.shell.id] ?? null, nextThread.session) - ) { - nextState = { - ...nextState, - threadSessionById: { - ...nextState.threadSessionById, - [nextThread.shell.id]: nextThread.session, - }, - }; - } - - if ( - !threadTurnStatesEqual(state.threadTurnStateById[nextThread.shell.id], nextThread.turnState) - ) { - nextState = { - ...nextState, - threadTurnStateById: { - ...nextState.threadTurnStateById, - [nextThread.shell.id]: nextThread.turnState, - }, - }; - } - - if ( - !sidebarThreadSummariesEqual( - state.sidebarThreadSummaryById[nextThread.shell.id], - nextThread.summary, - ) - ) { - nextState = { - ...nextState, - sidebarThreadSummaryById: { - ...nextState.sidebarThreadSummaryById, - [nextThread.shell.id]: nextThread.summary, - }, - }; - } - - return nextState; -} - -function retainThreadScopedRecord( - record: Record, - nextThreadIds: ReadonlySet, -): Record { - return Object.fromEntries( - Object.entries(record).flatMap(([threadId, value]) => - nextThreadIds.has(threadId as ThreadId) ? [[threadId, value] as const] : [], - ), - ) as Record; -} - -function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { - const shell = state.threadShellById[threadId]; - if (!shell) { - return state; - } - - const nextThreadIds = removeId(state.threadIds, threadId); - const currentProjectThreadIds = state.threadIdsByProjectId[shell.projectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = removeId(currentProjectThreadIds, threadId); - const nextThreadIdsByProjectId = - nextProjectThreadIds.length === 0 - ? (() => { - const { [shell.projectId]: _removed, ...rest } = state.threadIdsByProjectId; - return rest as Record; - })() - : { - ...state.threadIdsByProjectId, - [shell.projectId]: nextProjectThreadIds, - }; - - const { [threadId]: _removedShell, ...threadShellById } = state.threadShellById; - const { [threadId]: _removedSession, ...threadSessionById } = state.threadSessionById; - const { [threadId]: _removedTurnState, ...threadTurnStateById } = state.threadTurnStateById; - const { [threadId]: _removedMessageIds, ...messageIdsByThreadId } = state.messageIdsByThreadId; - const { [threadId]: _removedMessages, ...messageByThreadId } = state.messageByThreadId; - const { [threadId]: _removedActivityIds, ...activityIdsByThreadId } = state.activityIdsByThreadId; - const { [threadId]: _removedActivities, ...activityByThreadId } = state.activityByThreadId; - const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = - state.proposedPlanIdsByThreadId; - const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; - const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; - const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = - state.turnDiffSummaryByThreadId; - const { [threadId]: _removedSidebarSummary, ...sidebarThreadSummaryById } = - state.sidebarThreadSummaryById; - - return { - ...state, - threadIds: nextThreadIds, - threadIdsByProjectId: nextThreadIdsByProjectId, - threadShellById, - threadSessionById, - threadTurnStateById, - messageIdsByThreadId, - messageByThreadId, - activityIdsByThreadId, - activityByThreadId, - proposedPlanIdsByThreadId, - proposedPlanByThreadId, - turnDiffIdsByThreadId, - turnDiffSummaryByThreadId, - sidebarThreadSummaryById, - }; -} - -function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { - if (status === "error") { - return "error" as const; - } - if (status === "missing") { - return "interrupted" as const; - } - return "completed" as const; -} - -function compareActivities( - left: Thread["activities"][number], - right: Thread["activities"][number], -): number { - if (left.sequence !== undefined && right.sequence !== undefined) { - if (left.sequence !== right.sequence) { - return left.sequence - right.sequence; - } - } else if (left.sequence !== undefined) { - return 1; - } else if (right.sequence !== undefined) { - return -1; - } - - return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); -} - -function buildLatestTurn(params: { - previous: Thread["latestTurn"]; - turnId: NonNullable["turnId"]; - state: NonNullable["state"]; - requestedAt: string; - startedAt: string | null; - completedAt: string | null; - assistantMessageId: NonNullable["assistantMessageId"]; - sourceProposedPlan?: Thread["pendingSourceProposedPlan"]; -}): NonNullable { - const resolvedPlan = - params.previous?.turnId === params.turnId - ? params.previous.sourceProposedPlan - : params.sourceProposedPlan; - return { - turnId: params.turnId, - state: params.state, - requestedAt: params.requestedAt, - startedAt: params.startedAt, - completedAt: params.completedAt, - assistantMessageId: params.assistantMessageId, - ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}), - }; -} - -/** - * Turn state to settle a still-running latest turn with when its session - * leaves the "running" status, or null while the session is (re)starting or - * running and the turn must stay unsettled. - */ -function settledTurnStateForSessionStatus( - status: OrchestrationSessionStatus, -): "completed" | "interrupted" | "error" | null { - switch (status) { - case "idle": - case "ready": - return "completed"; - case "error": - return "error"; - case "interrupted": - case "stopped": - return "interrupted"; - case "starting": - case "running": - return null; - } -} - -function rebindTurnDiffSummariesForAssistantMessage( - turnDiffSummaries: ReadonlyArray, - turnId: TurnId, - assistantMessageId: NonNullable["assistantMessageId"], -): TurnDiffSummary[] { - let changed = false; - const nextSummaries = turnDiffSummaries.map((summary) => { - if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { - return summary; - } - changed = true; - return { - ...summary, - assistantMessageId: assistantMessageId ?? undefined, - }; - }); - return changed ? nextSummaries : [...turnDiffSummaries]; -} - -function retainThreadMessagesAfterRevert( - messages: ReadonlyArray, - retainedTurnIds: ReadonlySet, - turnCount: number, -): ChatMessage[] { - const retainedMessageIds = new Set(); - for (const message of messages) { - if (message.role === "system") { - retainedMessageIds.add(message.id); - continue; - } - if ( - message.turnId !== undefined && - message.turnId !== null && - retainedTurnIds.has(message.turnId) - ) { - retainedMessageIds.add(message.id); - } - } - - const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), - ).length; - const missingUserCount = Math.max(0, turnCount - retainedUserCount); - if (missingUserCount > 0) { - const fallbackUserMessages = messages - .filter( - (message) => - message.role === "user" && - !retainedMessageIds.has(message.id) && - (message.turnId === undefined || - message.turnId === null || - retainedTurnIds.has(message.turnId)), - ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(0, missingUserCount); - for (const message of fallbackUserMessages) { - retainedMessageIds.add(message.id); - } - } - - const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), - ).length; - const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); - if (missingAssistantCount > 0) { - const fallbackAssistantMessages = messages - .filter( - (message) => - message.role === "assistant" && - !retainedMessageIds.has(message.id) && - (message.turnId === undefined || - message.turnId === null || - retainedTurnIds.has(message.turnId)), - ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(0, missingAssistantCount); - for (const message of fallbackAssistantMessages) { - retainedMessageIds.add(message.id); - } - } - - return messages.filter((message) => retainedMessageIds.has(message.id)); -} - -function retainThreadActivitiesAfterRevert( - activities: ReadonlyArray, - retainedTurnIds: ReadonlySet, -): OrchestrationThreadActivity[] { - return activities.filter( - (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), - ); -} - -function retainThreadProposedPlansAfterRevert( - proposedPlans: ReadonlyArray, - retainedTurnIds: ReadonlySet, -): ProposedPlan[] { - return proposedPlans.filter( - (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), - ); -} - -function toLegacySessionStatus( - status: OrchestrationSessionStatus, -): "connecting" | "ready" | "running" | "error" | "closed" { - switch (status) { - case "starting": - return "connecting"; - case "running": - return "running"; - case "error": - return "error"; - case "ready": - case "interrupted": - return "ready"; - case "idle": - case "stopped": - return "closed"; - } -} - -function toLegacyProvider(providerName: string | null): ProviderDriverKind { - if (isProviderDriverKindValue(providerName)) { - return providerName; - } - return ProviderDriverKind.make("codex"); -} - -function updateThreadState( - state: EnvironmentState, - threadId: ThreadId, - updater: (thread: Thread) => Thread, -): EnvironmentState { - const currentThread = getThreadFromEnvironmentState(state, threadId); - if (!currentThread) { - return state; - } - const nextThread = updater(currentThread); - if (nextThread === currentThread) { - return state; - } - return writeThreadState(state, nextThread, currentThread); -} - -function buildProjectState( - projects: ReadonlyArray, -): Pick { - return { - projectIds: projects.map((project) => project.id), - projectById: Object.fromEntries( - projects.map((project) => [project.id, project] as const), - ) as Record, - }; -} - -function getStoredEnvironmentState( - state: AppState, - environmentId: EnvironmentId, -): EnvironmentState { - return state.environmentStateById[environmentId] ?? initialEnvironmentState; -} - -function commitEnvironmentState( - state: AppState, - environmentId: EnvironmentId, - nextEnvironmentState: EnvironmentState, -): AppState { - const currentEnvironmentState = state.environmentStateById[environmentId]; - const environmentStateById = - currentEnvironmentState === nextEnvironmentState - ? state.environmentStateById - : { - ...state.environmentStateById, - [environmentId]: nextEnvironmentState, - }; - - if (environmentStateById === state.environmentStateById) { - return state; - } - - return { - ...state, - environmentStateById, - }; -} - -function syncEnvironmentShellSnapshot( - state: EnvironmentState, - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, -): EnvironmentState { - const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId)); - const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); - let nextState: EnvironmentState = { - ...state, - ...buildProjectState(nextProjects), - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - sidebarThreadSummaryById: {}, - messageIdsByThreadId: retainThreadScopedRecord(state.messageIdsByThreadId, nextThreadIds), - messageByThreadId: retainThreadScopedRecord(state.messageByThreadId, nextThreadIds), - activityIdsByThreadId: retainThreadScopedRecord(state.activityIdsByThreadId, nextThreadIds), - activityByThreadId: retainThreadScopedRecord(state.activityByThreadId, nextThreadIds), - proposedPlanIdsByThreadId: retainThreadScopedRecord( - state.proposedPlanIdsByThreadId, - nextThreadIds, - ), - proposedPlanByThreadId: retainThreadScopedRecord(state.proposedPlanByThreadId, nextThreadIds), - turnDiffIdsByThreadId: retainThreadScopedRecord(state.turnDiffIdsByThreadId, nextThreadIds), - turnDiffSummaryByThreadId: retainThreadScopedRecord( - state.turnDiffSummaryByThreadId, - nextThreadIds, - ), - bootstrapComplete: true, - }; - - for (const thread of snapshot.threads) { - nextState = writeThreadShellState(nextState, mapThreadShell(thread, environmentId)); - } - - return nextState; -} - -export function syncServerShellSnapshot( - state: AppState, - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, -): AppState { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Keep web-specific projection here only until the store can consume - // createShellSnapshotManager or a shared adapter over its reducer. - return commitEnvironmentState( - state, - environmentId, - syncEnvironmentShellSnapshot( - getStoredEnvironmentState(state, environmentId), - snapshot, - environmentId, - ), - ); -} - -export function syncServerThreadDetail( - state: AppState, - thread: OrchestrationThread, - environmentId: EnvironmentId, -): AppState { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Keep web-specific projection here only until the store can consume - // createThreadDetailManager or a shared adapter over its reducer. - const environmentState = getStoredEnvironmentState(state, environmentId); - const previousThread = getThreadFromEnvironmentState(environmentState, thread.id); - return commitEnvironmentState( - state, - environmentId, - writeThreadState(environmentState, mapThread(thread, environmentId), previousThread), - ); -} - -function applyEnvironmentOrchestrationEvent( - state: EnvironmentState, - event: OrchestrationEvent, - environmentId: EnvironmentId, -): EnvironmentState { - switch (event.type) { - case "project.created": { - const nextProject = mapProject( - { - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - repositoryIdentity: event.payload.repositoryIdentity ?? null, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }, - environmentId, - ); - const existingProjectId = - state.projectIds.find( - (projectId) => - projectId === event.payload.projectId || - state.projectById[projectId]?.cwd === event.payload.workspaceRoot, - ) ?? null; - let projectById = state.projectById; - let projectIds = state.projectIds; - - if (existingProjectId !== null && existingProjectId !== nextProject.id) { - const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; - projectById = { - ...restProjectById, - [nextProject.id]: nextProject, - }; - projectIds = state.projectIds.map((projectId) => - projectId === existingProjectId ? nextProject.id : projectId, - ); - } else { - projectById = { - ...state.projectById, - [nextProject.id]: nextProject, - }; - projectIds = - existingProjectId === null && !state.projectIds.includes(nextProject.id) - ? [...state.projectIds, nextProject.id] - : state.projectIds; - } - - return { - ...state, - projectById, - projectIds, - }; - } - - case "project.meta-updated": { - const project = state.projectById[event.payload.projectId]; - if (!project) { - return state; - } - const nextProject: Project = { - ...project, - ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), - ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), - ...(event.payload.repositoryIdentity !== undefined - ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } - : {}), - ...(event.payload.defaultModelSelection !== undefined - ? { - defaultModelSelection: event.payload.defaultModelSelection - ? normalizeModelSelection(event.payload.defaultModelSelection) - : null, - } - : {}), - ...(event.payload.scripts !== undefined - ? { scripts: mapProjectScripts(event.payload.scripts) } - : {}), - updatedAt: event.payload.updatedAt, - }; - return { - ...state, - projectById: { - ...state.projectById, - [event.payload.projectId]: nextProject, - }, - }; - } - - case "project.deleted": { - if (!state.projectById[event.payload.projectId]) { - return state; - } - const { [event.payload.projectId]: _removedProject, ...projectById } = state.projectById; - return { - ...state, - projectById, - projectIds: removeId(state.projectIds, event.payload.projectId), - }; - } - - case "thread.created": { - const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId); - const nextThread = mapThread( - { - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - goal: null, - }, - environmentId, - ); - return writeThreadState(state, nextThread, previousThread); - } - - case "thread.deleted": - return removeThreadState(state, event.payload.threadId); - - case "thread.archived": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - archivedAt: event.payload.archivedAt, - updatedAt: event.payload.updatedAt, - })); - - case "thread.unarchived": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - archivedAt: null, - updatedAt: event.payload.updatedAt, - })); - - case "thread.meta-updated": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), - ...(event.payload.modelSelection !== undefined - ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } - : {}), - ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), - ...(event.payload.worktreePath !== undefined - ? { worktreePath: event.payload.worktreePath } - : {}), - updatedAt: event.payload.updatedAt, - })); - - case "thread.runtime-mode-set": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - runtimeMode: event.payload.runtimeMode, - updatedAt: event.payload.updatedAt, - })); - - case "thread.interaction-mode-set": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - interactionMode: event.payload.interactionMode, - updatedAt: event.payload.updatedAt, - })); - - case "thread.turn-start-requested": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - ...(event.payload.modelSelection !== undefined - ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } - : {}), - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - pendingSourceProposedPlan: event.payload.sourceProposedPlan, - updatedAt: event.occurredAt, - })); - - case "thread.turn-interrupt-requested": { - if (event.payload.turnId === undefined) { - return state; - } - return updateThreadState(state, event.payload.threadId, (thread) => { - const latestTurn = thread.latestTurn; - if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { - return thread; - } - return { - ...thread, - latestTurn: buildLatestTurn({ - previous: latestTurn, - turnId: event.payload.turnId, - state: "interrupted", - requestedAt: latestTurn.requestedAt, - startedAt: latestTurn.startedAt ?? event.payload.createdAt, - completedAt: latestTurn.completedAt ?? event.payload.createdAt, - assistantMessageId: latestTurn.assistantMessageId, - }), - updatedAt: event.occurredAt, - }; - }); - } - - case "thread.message-sent": - return updateThreadState(state, event.payload.threadId, (thread) => { - const message = mapMessage(thread.environmentId, { - id: event.payload.messageId, - role: event.payload.role, - text: event.payload.text, - ...(event.payload.attachments !== undefined - ? { attachments: event.payload.attachments } - : {}), - turnId: event.payload.turnId, - streaming: event.payload.streaming, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - }); - const existingMessage = thread.messages.find((entry) => entry.id === message.id); - const messages = existingMessage - ? thread.messages.map((entry) => - entry.id !== message.id - ? entry - : { - ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, - streaming: message.streaming, - ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), - ...(message.streaming - ? entry.completedAt !== undefined - ? { completedAt: entry.completedAt } - : {} - : message.completedAt !== undefined - ? { completedAt: message.completedAt } - : {}), - ...(message.attachments !== undefined - ? { attachments: message.attachments } - : {}), - }, - ) - : [...thread.messages, message]; - const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); - const turnDiffSummaries = - event.payload.role === "assistant" && event.payload.turnId !== null - ? rebindTurnDiffSummariesForAssistantMessage( - thread.turnDiffSummaries, - event.payload.turnId, - event.payload.messageId, - ) - : thread.turnDiffSummaries; - // A completed assistant message only settles the turn once the - // session is no longer running it — providers may emit several - // assistant messages per turn (commentary between tool calls), and - // the turn must stay unsettled until the provider reports turn end. - const turnStillRunning = - event.payload.turnId !== null && - thread.session?.orchestrationStatus === "running" && - thread.session.activeTurnId === event.payload.turnId; - const settlesTurn = !event.payload.streaming && !turnStillRunning; - const latestTurn: Thread["latestTurn"] = - event.payload.role === "assistant" && - event.payload.turnId !== null && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: settlesTurn - ? thread.latestTurn?.state === "interrupted" - ? "interrupted" - : thread.latestTurn?.state === "error" - ? "error" - : "completed" - : "running", - requestedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? thread.latestTurn.requestedAt - : event.payload.createdAt, - startedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.startedAt ?? event.payload.createdAt) - : event.payload.createdAt, - sourceProposedPlan: thread.pendingSourceProposedPlan, - completedAt: settlesTurn - ? event.payload.updatedAt - : thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.completedAt ?? null) - : null, - assistantMessageId: event.payload.messageId, - }) - : thread.latestTurn; - return { - ...thread, - messages: cappedMessages, - turnDiffSummaries, - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.session-set": - return updateThreadState(state, event.payload.threadId, (thread) => { - // Leaving the "running" session status is the turn-end signal: - // settle a still-running latest turn so its duration reflects the - // whole turn, not the last assistant message. - const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); - const latestTurn: Thread["latestTurn"] = - event.payload.session.status === "running" && event.payload.session.activeTurnId !== null - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.session.activeTurnId, - state: "running", - requestedAt: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? thread.latestTurn.requestedAt - : event.payload.session.updatedAt, - startedAt: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) - : event.payload.session.updatedAt, - completedAt: null, - assistantMessageId: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? thread.latestTurn.assistantMessageId - : null, - sourceProposedPlan: thread.pendingSourceProposedPlan, - }) - : thread.latestTurn !== null && - thread.latestTurn.state === "running" && - settledTurnState !== null - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: thread.latestTurn.turnId, - state: settledTurnState, - requestedAt: thread.latestTurn.requestedAt, - startedAt: thread.latestTurn.startedAt, - // A running turn's completedAt can only hold a mid-turn - // placeholder checkpoint timestamp — the session leaving - // "running" is the authoritative turn end. - completedAt: event.payload.session.updatedAt, - assistantMessageId: thread.latestTurn.assistantMessageId, - }) - : thread.latestTurn; - return { - ...thread, - session: mapSession(event.payload.session), - error: sanitizeThreadErrorMessage(event.payload.session.lastError), - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.goal-updated": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - goal: event.payload.goal, - updatedAt: event.occurredAt, - })); - - case "thread.goal-cleared": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - goal: null, - updatedAt: event.occurredAt, - })); - - case "thread.session-stop-requested": - return updateThreadState(state, event.payload.threadId, (thread) => - thread.session === null - ? thread - : { - ...thread, - session: { - ...thread.session, - status: "closed", - orchestrationStatus: "stopped", - activeTurnId: undefined, - updatedAt: event.payload.createdAt, - }, - updatedAt: event.occurredAt, - }, - ); - - case "thread.proposed-plan-upserted": - return updateThreadState(state, event.payload.threadId, (thread) => { - const proposedPlan = mapProposedPlan(event.payload.proposedPlan); - const proposedPlans = [ - ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), - proposedPlan, - ] - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(-MAX_THREAD_PROPOSED_PLANS); - return { - ...thread, - proposedPlans, - updatedAt: event.occurredAt, - }; - }); - - case "thread.turn-diff-completed": - return updateThreadState(state, event.payload.threadId, (thread) => { - const checkpoint = mapTurnDiffSummary({ - turnId: event.payload.turnId, - checkpointTurnCount: event.payload.checkpointTurnCount, - checkpointRef: event.payload.checkpointRef, - status: event.payload.status, - files: event.payload.files, - assistantMessageId: event.payload.assistantMessageId, - completedAt: event.payload.completedAt, - }); - const existing = thread.turnDiffSummaries.find( - (entry) => entry.turnId === checkpoint.turnId, - ); - if (existing && existing.status !== "missing" && checkpoint.status === "missing") { - return thread; - } - const turnDiffSummaries = [ - ...thread.turnDiffSummaries.filter((entry) => entry.turnId !== checkpoint.turnId), - checkpoint, - ] - .toSorted( - (left, right) => - (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - - (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), - ) - .slice(-MAX_THREAD_CHECKPOINTS); - // Mid-turn diff updates produce placeholder checkpoints; record the - // diff summary, but don't settle a turn its session is still running. - const turnStillRunning = - thread.session?.orchestrationStatus === "running" && - thread.session.activeTurnId === event.payload.turnId; - const latestTurn = - !turnStillRunning && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: checkpointStatusToLatestTurnState(event.payload.status), - requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, - startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, - completedAt: event.payload.completedAt, - assistantMessageId: event.payload.assistantMessageId, - sourceProposedPlan: thread.pendingSourceProposedPlan, - }) - : thread.latestTurn; - return { - ...thread, - turnDiffSummaries, - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.reverted": - return updateThreadState(state, event.payload.threadId, (thread) => { - const turnDiffSummaries = thread.turnDiffSummaries - .filter( - (entry) => - entry.checkpointTurnCount !== undefined && - entry.checkpointTurnCount <= event.payload.turnCount, - ) - .toSorted( - (left, right) => - (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - - (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), - ) - .slice(-MAX_THREAD_CHECKPOINTS); - const retainedTurnIds = new Set(turnDiffSummaries.map((entry) => entry.turnId)); - const messages = retainThreadMessagesAfterRevert( - thread.messages, - retainedTurnIds, - event.payload.turnCount, - ).slice(-MAX_THREAD_MESSAGES); - const proposedPlans = retainThreadProposedPlansAfterRevert( - thread.proposedPlans, - retainedTurnIds, - ).slice(-MAX_THREAD_PROPOSED_PLANS); - const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); - const latestCheckpoint = turnDiffSummaries.at(-1) ?? null; - - return { - ...thread, - turnDiffSummaries, - messages, - proposedPlans, - activities, - pendingSourceProposedPlan: undefined, - latestTurn: - latestCheckpoint === null - ? null - : { - turnId: latestCheckpoint.turnId, - state: checkpointStatusToLatestTurnState( - (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error", - ), - requestedAt: latestCheckpoint.completedAt, - startedAt: latestCheckpoint.completedAt, - completedAt: latestCheckpoint.completedAt, - assistantMessageId: latestCheckpoint.assistantMessageId ?? null, - }, - updatedAt: event.occurredAt, - }; - }); - - case "thread.activity-appended": - return updateThreadState(state, event.payload.threadId, (thread) => { - const activities = [ - ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), - { ...event.payload.activity }, - ] - .toSorted(compareActivities) - .slice(-MAX_THREAD_ACTIVITIES); - return { - ...thread, - activities, - updatedAt: event.occurredAt, - }; - }); - - case "thread.approval-response-requested": - case "thread.user-input-response-requested": - return state; - } - - return state; -} - -function applyEnvironmentShellEvent( - state: EnvironmentState, - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, -): EnvironmentState { - switch (event.kind) { - case "project-upserted": { - const nextProject = mapProject(event.project, environmentId); - const existingProjectId = - state.projectIds.find( - (projectId) => - projectId === event.project.id || - state.projectById[projectId]?.cwd === event.project.workspaceRoot, - ) ?? null; - let projectById = state.projectById; - let projectIds = state.projectIds; - - if (existingProjectId !== null && existingProjectId !== nextProject.id) { - const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; - projectById = { - ...restProjectById, - [nextProject.id]: nextProject, - }; - projectIds = state.projectIds.map((projectId) => - projectId === existingProjectId ? nextProject.id : projectId, - ); - } else { - projectById = { - ...state.projectById, - [nextProject.id]: nextProject, - }; - projectIds = - existingProjectId === null && !state.projectIds.includes(nextProject.id) - ? [...state.projectIds, nextProject.id] - : state.projectIds; - } - - return { - ...state, - projectById, - projectIds, - }; - } - case "project-removed": { - if (!state.projectById[event.projectId]) { - return state; - } - const { [event.projectId]: _removedProject, ...projectById } = state.projectById; - return { - ...state, - projectById, - projectIds: removeId(state.projectIds, event.projectId), - }; - } - case "thread-upserted": - return writeThreadShellState(state, mapThreadShell(event.thread, environmentId)); - case "thread-removed": - return removeThreadState(state, event.threadId); - } -} - -export function applyOrchestrationEvents( - state: AppState, - events: ReadonlyArray, - environmentId: EnvironmentId, -): AppState { - if (events.length === 0) { - return state; - } - const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); - const nextEnvironmentState = events.reduce( - (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), - currentEnvironmentState, - ); - return commitEnvironmentState(state, environmentId, nextEnvironmentState); -} - -function getEnvironmentEntries( - state: AppState, -): ReadonlyArray { - return Object.entries(state.environmentStateById) as unknown as ReadonlyArray< - readonly [EnvironmentId, EnvironmentState] - >; -} - -export function selectEnvironmentState( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): EnvironmentState { - return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState; -} - -export function selectProjectsForEnvironment( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): Project[] { - return getProjects(selectEnvironmentState(state, environmentId)); -} - -export function selectThreadsForEnvironment( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): Thread[] { - return getThreads(selectEnvironmentState(state, environmentId)); -} - -export function selectProjectsAcrossEnvironments(state: AppState): Project[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - getProjects(environmentState), - ); -} - -export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - getThreads(environmentState), - ); -} - -/** Like `selectThreadsAcrossEnvironments` but returns stable `ThreadShell` references from the store (no derived data). */ -export function selectThreadShellsAcrossEnvironments(state: AppState): ThreadShell[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - environmentState.threadIds.flatMap((threadId) => { - const shell = environmentState.threadShellById[threadId]; - return shell ? [shell] : []; - }), - ); -} - -export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { - return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => - environmentState.threadIds.flatMap((threadId) => { - const thread = environmentState.sidebarThreadSummaryById[threadId]; - return thread && thread.environmentId === environmentId ? [thread] : []; - }), - ); -} - -export function selectSidebarThreadsForProjectRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): SidebarThreadSummary[] { - if (!ref) { - return []; - } - - const environmentState = selectEnvironmentState(state, ref.environmentId); - const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS; - return threadIds.flatMap((threadId) => { - const thread = environmentState.sidebarThreadSummaryById[threadId]; - return thread ? [thread] : []; - }); -} - -export function selectSidebarThreadsForProjectRefs( - state: AppState, - refs: readonly ScopedProjectRef[], -): SidebarThreadSummary[] { - if (refs.length === 0) return []; - if (refs.length === 1) return selectSidebarThreadsForProjectRef(state, refs[0]); - return refs.flatMap((ref) => selectSidebarThreadsForProjectRef(state, ref)); -} - -export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { - return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; -} - -export function selectProjectByRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): Project | undefined { - return ref - ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] - : undefined; -} - -export function selectThreadByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): Thread | undefined { - return ref - ? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId) - : undefined; -} - -export function selectThreadExistsByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): boolean { - return ref - ? selectEnvironmentState(state, ref.environmentId).threadShellById[ref.threadId] !== undefined - : false; -} - -export function selectSidebarThreadSummaryByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): SidebarThreadSummary | undefined { - return ref - ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] - : undefined; -} - -export function selectThreadIdsByProjectRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): ThreadId[] { - return ref - ? (selectEnvironmentState(state, ref.environmentId).threadIdsByProjectId[ref.projectId] ?? - EMPTY_THREAD_IDS) - : EMPTY_THREAD_IDS; -} - -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - if (state.activeEnvironmentId === null) { - return state; - } - - const nextEnvironmentState = updateThreadState( - getStoredEnvironmentState(state, state.activeEnvironmentId), - threadId, - (thread) => { - if (thread.error === error) return thread; - return { ...thread, error }; - }, - ); - return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); -} - -export function applyOrchestrationEvent( - state: AppState, - event: OrchestrationEvent, - environmentId: EnvironmentId, -): AppState { - return commitEnvironmentState( - state, - environmentId, - applyEnvironmentOrchestrationEvent( - getStoredEnvironmentState(state, environmentId), - event, - environmentId, - ), - ); -} - -export function applyShellEvent( - state: AppState, - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, -): AppState { - return commitEnvironmentState( - state, - environmentId, - applyEnvironmentShellEvent( - getStoredEnvironmentState(state, environmentId), - event, - environmentId, - ), - ); -} - -export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { - if (state.activeEnvironmentId === environmentId) { - return state; - } - - return { - ...state, - activeEnvironmentId: environmentId, - }; -} - -export function removeEnvironmentState(state: AppState, environmentId: EnvironmentId): AppState { - if (!state.environmentStateById[environmentId] && state.activeEnvironmentId !== environmentId) { - return state; - } - - const { [environmentId]: _removed, ...environmentStateById } = state.environmentStateById; - return { - ...state, - activeEnvironmentId: - state.activeEnvironmentId === environmentId ? null : state.activeEnvironmentId, - environmentStateById, - }; -} - -export function setThreadBranch( - state: AppState, - threadRef: ScopedThreadRef, - branch: string | null, - worktreePath: string | null, -): AppState { - const nextEnvironmentState = updateThreadState( - getStoredEnvironmentState(state, threadRef.environmentId), - threadRef.threadId, - (thread) => { - if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; - const cwdChanged = thread.worktreePath !== worktreePath; - return { - ...thread, - branch, - worktreePath, - ...(cwdChanged ? { session: null } : {}), - }; - }, - ); - return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); -} - -interface AppStore extends AppState { - setActiveEnvironmentId: (environmentId: EnvironmentId) => void; - removeEnvironmentState: (environmentId: EnvironmentId) => void; - syncServerShellSnapshot: ( - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, - ) => void; - syncServerThreadDetail: (thread: OrchestrationThread, environmentId: EnvironmentId) => void; - applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; - applyOrchestrationEvents: ( - events: ReadonlyArray, - environmentId: EnvironmentId, - ) => void; - applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void; - setError: (threadId: ThreadId, error: string | null) => void; - setThreadBranch: ( - threadRef: ScopedThreadRef, - branch: string | null, - worktreePath: string | null, - ) => void; -} - -export const useStore = create((set) => ({ - ...initialState, - setActiveEnvironmentId: (environmentId) => - set((state) => setActiveEnvironmentId(state, environmentId)), - removeEnvironmentState: (environmentId) => - set((state) => removeEnvironmentState(state, environmentId)), - syncServerShellSnapshot: (snapshot, environmentId) => - set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), - syncServerThreadDetail: (thread, environmentId) => - set((state) => syncServerThreadDetail(state, thread, environmentId)), - applyOrchestrationEvent: (event, environmentId) => - set((state) => applyOrchestrationEvent(state, event, environmentId)), - applyOrchestrationEvents: (events, environmentId) => - set((state) => applyOrchestrationEvents(state, events, environmentId)), - applyShellEvent: (event, environmentId) => - set((state) => applyShellEvent(state, event, environmentId)), - setError: (threadId, error) => set((state) => setError(state, threadId, error)), - setThreadBranch: (threadRef, branch, worktreePath) => - set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), -})); diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts deleted file mode 100644 index 95ed6ff1f41..00000000000 --- a/apps/web/src/storeSelectors.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; -import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; -import { type Project, type Thread } from "./types"; -import { getThreadFromEnvironmentState } from "./threadDerivation"; - -export function createProjectSelectorByRef( - ref: ScopedProjectRef | null | undefined, -): (state: AppState) => Project | undefined { - return (state) => - ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; -} - -function createScopedThreadSelector( - resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, -): (state: AppState) => Thread | undefined { - let previousEnvironmentState: EnvironmentState | undefined; - let previousThreadId: ThreadId | undefined; - let previousThread: Thread | undefined; - - return (state) => { - const ref = resolveRef(state); - if (!ref) { - return undefined; - } - - const environmentState = selectEnvironmentState(state, ref.environmentId); - if ( - previousThread && - previousEnvironmentState === environmentState && - previousThreadId === ref.threadId - ) { - return previousThread; - } - - previousEnvironmentState = environmentState; - previousThreadId = ref.threadId; - previousThread = getThreadFromEnvironmentState(environmentState, ref.threadId); - return previousThread; - }; -} - -export function createThreadSelectorByRef( - ref: ScopedThreadRef | null | undefined, -): (state: AppState) => Thread | undefined { - return createScopedThreadSelector(() => ref); -} - -export function createThreadSelectorAcrossEnvironments( - threadId: ThreadId | null | undefined, -): (state: AppState) => Thread | undefined { - return createScopedThreadSelector((state) => { - if (!threadId) { - return undefined; - } - - for (const [environmentId, environmentState] of Object.entries( - state.environmentStateById, - ) as Array<[ScopedThreadRef["environmentId"], EnvironmentState]>) { - if (environmentState.threadShellById[threadId]) { - return { - environmentId, - threadId, - }; - } - } - return undefined; - }); -} diff --git a/apps/web/src/terminalSessionState.ts b/apps/web/src/terminalSessionState.ts deleted file mode 100644 index e4209ca61fe..00000000000 --- a/apps/web/src/terminalSessionState.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, - EMPTY_TERMINAL_ID_LIST_ATOM, - EMPTY_TERMINAL_SESSION_ATOM, - createTerminalSessionManager, - getKnownTerminalSessionTarget, - getKnownTerminalSessionListFilter, - knownTerminalSessionsAtom, - runningTerminalIdsAtom, - terminalSessionStateAtom, - type KnownTerminalSession, - type TerminalSessionTarget, - type TerminalSessionState, - type TerminalSubscribeMetadataInput, - type TerminalAttachSessionInput, -} from "@t3tools/client-runtime"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; - -import { appAtomRegistry } from "./rpc/atomRegistry"; - -export const terminalSessionManager = createTerminalSessionManager({ - getRegistry: () => appAtomRegistry, -}); - -export function subscribeTerminalMetadata( - input: TerminalSubscribeMetadataInput & { - readonly environmentId: EnvironmentId; - }, -) { - return terminalSessionManager.subscribeMetadata(input); -} - -export function attachTerminalSession( - input: TerminalAttachSessionInput & { - readonly environmentId: EnvironmentId; - }, -) { - return terminalSessionManager.attach({ - environmentId: input.environmentId, - client: input.client, - terminal: input.terminal, - ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), - ...(input.onEvent ? { onEvent: input.onEvent } : {}), - }); -} - -export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { - const target = getKnownTerminalSessionTarget(input); - return useAtomValue( - target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, - ); -} - -export function useKnownTerminalSessions(input: { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; -}): ReadonlyArray { - const filter = getKnownTerminalSessionListFilter(input); - return useAtomValue( - filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, - ); -} - -export function useThreadRunningTerminalIds(input: { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; -}): ReadonlyArray { - const filter = getKnownTerminalSessionListFilter(input); - return useAtomValue( - filter !== null ? runningTerminalIdsAtom(filter) : EMPTY_TERMINAL_ID_LIST_ATOM, - ); -} diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index c4d4e9ff8a6..b0b1df96e1f 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; @@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => { useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, + suppressedTerminalIdsByThreadKey: {}, }); }); @@ -261,6 +262,29 @@ describe("terminalUiStateStore actions", () => { ]); }); + it("does not import a closed panel terminal from stale metadata", () => { + const store = useTerminalUiStateStore.getState(); + store.newTerminal(THREAD_REF, "term-2"); + store.closeTerminal(THREAD_REF, "term-1"); + + store.reconcileTerminalIds(THREAD_REF, ["term-1", "term-2"]); + + expect( + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ).terminalIds, + ).toEqual(["term-2"]); + + store.newTerminal(THREAD_REF, "term-1"); + expect( + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ).terminalIds, + ).toEqual(["term-2", "term-1"]); + }); + it("is a no-op when clearing terminal UI state for a thread with no state", () => { const store = useTerminalUiStateStore.getState(); const before = useTerminalUiStateStore.getState(); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index e5262bfcf7c..290ca8e5954 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -5,7 +5,7 @@ * API constrained to store actions/selectors. */ -import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -519,8 +519,51 @@ function updateTerminalUiStateByThreadKey( }; } +function updateSuppressedTerminalId( + suppressedTerminalIdsByThreadKey: Record, + threadRef: ScopedThreadRef, + terminalId: string, + suppressed: boolean, +): Record { + const normalizedTerminalId = terminalId.trim(); + if (normalizedTerminalId.length === 0) { + return suppressedTerminalIdsByThreadKey; + } + const threadKey = terminalThreadKey(threadRef); + const currentIds = suppressedTerminalIdsByThreadKey[threadKey] ?? []; + const currentlySuppressed = currentIds.includes(normalizedTerminalId); + if (currentlySuppressed === suppressed) { + return suppressedTerminalIdsByThreadKey; + } + if (suppressed) { + return { + ...suppressedTerminalIdsByThreadKey, + [threadKey]: [...currentIds, normalizedTerminalId], + }; + } + + const remainingIds = currentIds.filter((id) => id !== normalizedTerminalId); + if (remainingIds.length > 0) { + return { + ...suppressedTerminalIdsByThreadKey, + [threadKey]: remainingIds, + }; + } + return removeRecordEntry(suppressedTerminalIdsByThreadKey, threadKey); +} + +function removeRecordEntry(record: Record, key: string): Record { + if (record[key] === undefined) { + return record; + } + const { [key]: _removed, ...remaining } = record; + return remaining; +} + interface TerminalUiStateStoreState { terminalUiStateByThreadKey: Record; + /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ + suppressedTerminalIdsByThreadKey: Record; setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; @@ -541,106 +584,186 @@ interface TerminalUiStateStoreState { export const useTerminalUiStateStore = create()( persist( - (set) => { + (set, get) => { const updateTerminal = ( threadRef: ScopedThreadRef, - updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState, + updater: ( + state: ThreadTerminalUiState, + suppressedTerminalIds: readonly string[], + ) => ThreadTerminalUiState, + suppression?: { terminalId: string; suppressed: boolean }, ) => { set((state) => { + const threadKey = terminalThreadKey(threadRef); + const suppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] ?? []; const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( state.terminalUiStateByThreadKey, threadRef, - updater, + (terminalState) => updater(terminalState, suppressedTerminalIds), ); - if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) { + const nextSuppressedTerminalIdsByThreadKey = suppression + ? updateSuppressedTerminalId( + state.suppressedTerminalIdsByThreadKey, + threadRef, + suppression.terminalId, + suppression.suppressed, + ) + : state.suppressedTerminalIdsByThreadKey; + if ( + nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && + nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey + ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }); }; return { terminalUiStateByThreadKey: {}, - setTerminalOpen: (threadRef, open) => - updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), + suppressedTerminalIdsByThreadKey: {}, + setTerminalOpen: (threadRef, open) => { + const terminalState = selectThreadTerminalUiState( + get().terminalUiStateByThreadKey, + threadRef, + ); + updateTerminal( + threadRef, + (state) => setThreadTerminalOpen(state, open), + open && terminalState.terminalIds.length === 0 + ? { terminalId: DEFAULT_THREAD_TERMINAL_ID, suppressed: false } + : undefined, + ); + }, setTerminalHeight: (threadRef, height) => updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), splitTerminal: (threadRef, terminalId) => - updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), + updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId), { + terminalId, + suppressed: false, + }), splitTerminalVertical: (threadRef, terminalId) => - updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")), + updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical"), { + terminalId, + suppressed: false, + }), newTerminal: (threadRef, terminalId) => - updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), - ensureTerminal: (threadRef, terminalId, options) => - updateTerminal(threadRef, (state) => { - let nextState = state; - if (!state.terminalIds.includes(terminalId)) { - nextState = newThreadTerminal(nextState, terminalId); - } - if (options?.active === false) { - nextState = { - ...nextState, - activeTerminalId: state.activeTerminalId, - activeTerminalGroupId: state.activeTerminalGroupId, - }; - } - if (options?.active ?? true) { - nextState = setThreadActiveTerminal(nextState, terminalId); - } - if (options?.open) { - nextState = setThreadTerminalOpen(nextState, true); - } - return normalizeThreadTerminalUiState(nextState); + updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId), { + terminalId, + suppressed: false, }), + ensureTerminal: (threadRef, terminalId, options) => + updateTerminal( + threadRef, + (state) => { + let nextState = state; + if (!state.terminalIds.includes(terminalId)) { + nextState = newThreadTerminal(nextState, terminalId); + } + if (options?.active === false) { + nextState = { + ...nextState, + activeTerminalId: state.activeTerminalId, + activeTerminalGroupId: state.activeTerminalGroupId, + }; + } + if (options?.active ?? true) { + nextState = setThreadActiveTerminal(nextState, terminalId); + } + if (options?.open) { + nextState = setThreadTerminalOpen(nextState, true); + } + return normalizeThreadTerminalUiState(nextState); + }, + { terminalId, suppressed: false }, + ), setActiveTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), closeTerminal: (threadRef, terminalId) => - updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), + updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { + terminalId, + suppressed: true, + }), reconcileTerminalIds: (threadRef, nextIds) => - updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)), + updateTerminal(threadRef, (state, suppressedTerminalIds) => { + if (suppressedTerminalIds.length === 0) { + return reconcileThreadTerminalSessionIds(state, nextIds); + } + const suppressedIds = new Set(suppressedTerminalIds); + return reconcileThreadTerminalSessionIds( + state, + nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), + ); + }), clearTerminalUiState: (threadRef) => set((state) => { + const threadKey = terminalThreadKey(threadRef); const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( state.terminalUiStateByThreadKey, threadRef, () => createDefaultThreadTerminalUiState(), ); - if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) { + const hadSuppressedTerminalIds = + state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; + if ( + nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && + !hadSuppressedTerminalIds + ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + suppressedTerminalIdsByThreadKey: removeRecordEntry( + state.suppressedTerminalIdsByThreadKey, + threadKey, + ), }; }), removeTerminalUiState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; - if (!hadTerminalUiState) { + const hadSuppressedTerminalIds = + state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; + if (!hadTerminalUiState && !hadSuppressedTerminalIds) { return state; } - const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey }; - delete nextTerminalUiStateByThreadKey[threadKey]; return { - terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + terminalUiStateByThreadKey: removeRecordEntry( + state.terminalUiStateByThreadKey, + threadKey, + ), + suppressedTerminalIdsByThreadKey: removeRecordEntry( + state.suppressedTerminalIdsByThreadKey, + threadKey, + ), }; }), removeOrphanedTerminalUiStates: (activeThreadKeys) => set((state) => { - const orphanedIds = Object.keys(state.terminalUiStateByThreadKey).filter( - (key) => !activeThreadKeys.has(key), + const orphanedIds = new Set( + [ + ...Object.keys(state.terminalUiStateByThreadKey), + ...Object.keys(state.suppressedTerminalIdsByThreadKey), + ].filter((key) => !activeThreadKeys.has(key)), ); - if (orphanedIds.length === 0) { + if (orphanedIds.size === 0) { return state; } - const next = { ...state.terminalUiStateByThreadKey }; + const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey }; + const nextSuppressedTerminalIdsByThreadKey = { + ...state.suppressedTerminalIdsByThreadKey, + }; for (const id of orphanedIds) { - delete next[id]; + delete nextTerminalUiStateByThreadKey[id]; + delete nextSuppressedTerminalIdsByThreadKey[id]; } return { - terminalUiStateByThreadKey: next, + terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }), }; diff --git a/apps/web/src/threadDerivation.ts b/apps/web/src/threadDerivation.ts deleted file mode 100644 index 0766f0c8e13..00000000000 --- a/apps/web/src/threadDerivation.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; -import type { EnvironmentState } from "./store"; -import type { - ChatMessage, - ProposedPlan, - Thread, - ThreadSession, - ThreadShell, - ThreadTurnState, - TurnDiffSummary, -} from "./types"; - -const EMPTY_MESSAGES: ChatMessage[] = []; -const EMPTY_ACTIVITIES: Thread["activities"] = []; -const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; -const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; -const EMPTY_MESSAGE_MAP: Record = {}; -const EMPTY_ACTIVITY_MAP: Record = {}; -const EMPTY_PROPOSED_PLAN_MAP: Record = {}; -const EMPTY_TURN_DIFF_MAP: Record = {}; - -const collectedByIdsCache = new WeakMap>(); -const threadCache = new WeakMap< - ThreadShell, - { - session: ThreadSession | null; - turnState: ThreadTurnState | undefined; - messages: Thread["messages"]; - activities: Thread["activities"]; - proposedPlans: Thread["proposedPlans"]; - turnDiffSummaries: Thread["turnDiffSummaries"]; - thread: Thread; - } ->(); - -function collectByIds( - ids: readonly TKey[] | undefined, - byId: Record | undefined, - emptyValue: TValue[], -): TValue[] { - if (!ids || ids.length === 0 || !byId) { - return emptyValue; - } - - const cachedByRecord = collectedByIdsCache.get(ids); - const cached = cachedByRecord?.get(byId); - if (cached) { - return cached as TValue[]; - } - - const nextValues = ids.flatMap((id) => { - const value = byId[id]; - return value ? [value] : []; - }); - const nextCachedByRecord = cachedByRecord ?? new WeakMap(); - nextCachedByRecord.set(byId, nextValues); - if (!cachedByRecord) { - collectedByIdsCache.set(ids, nextCachedByRecord); - } - return nextValues; -} - -function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): Thread["messages"] { - return collectByIds( - state.messageIdsByThreadId[threadId], - state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP, - EMPTY_MESSAGES, - ); -} - -function selectThreadActivities(state: EnvironmentState, threadId: ThreadId): Thread["activities"] { - return collectByIds( - state.activityIdsByThreadId[threadId], - state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP, - EMPTY_ACTIVITIES, - ); -} - -function selectThreadProposedPlans( - state: EnvironmentState, - threadId: ThreadId, -): Thread["proposedPlans"] { - return collectByIds( - state.proposedPlanIdsByThreadId[threadId], - state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP, - EMPTY_PROPOSED_PLANS, - ); -} - -function selectThreadTurnDiffSummaries( - state: EnvironmentState, - threadId: ThreadId, -): Thread["turnDiffSummaries"] { - return collectByIds( - state.turnDiffIdsByThreadId[threadId], - state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP, - EMPTY_TURN_DIFF_SUMMARIES, - ); -} - -export function getThreadFromEnvironmentState( - state: EnvironmentState, - threadId: ThreadId, -): Thread | undefined { - const shell = state.threadShellById[threadId]; - if (!shell) { - return undefined; - } - - const session = state.threadSessionById[threadId] ?? null; - const turnState = state.threadTurnStateById[threadId]; - const messages = selectThreadMessages(state, threadId); - const activities = selectThreadActivities(state, threadId); - const proposedPlans = selectThreadProposedPlans(state, threadId); - const turnDiffSummaries = selectThreadTurnDiffSummaries(state, threadId); - const cached = threadCache.get(shell); - - if ( - cached && - cached.session === session && - cached.turnState === turnState && - cached.messages === messages && - cached.activities === activities && - cached.proposedPlans === proposedPlans && - cached.turnDiffSummaries === turnDiffSummaries - ) { - return cached.thread; - } - - const thread: Thread = { - ...shell, - session, - latestTurn: turnState?.latestTurn ?? null, - pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, - messages, - activities, - proposedPlans, - turnDiffSummaries, - }; - - threadCache.set(shell, { - session, - turnState, - messages, - activities, - proposedPlans, - turnDiffSummaries, - thread, - }); - - return thread; -} diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index e5a365b0889..d15a233a304 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { DraftId } from "./composerDraftStore"; diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 3fda9eb4235..19a7d5ca603 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { DraftId } from "./composerDraftStore"; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 1e7269f5eea..45a8539a151 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,23 +1,20 @@ import type { - EnvironmentId, - ModelSelection, + ChatImageAttachment as ContractChatImageAttachment, + OrchestrationCheckpointFile, + OrchestrationCheckpointSummary, OrchestrationLatestTurn, - OrchestrationProposedPlanId, - RepositoryIdentity, - OrchestrationSessionStatus, - OrchestrationThreadActivity, - OrchestrationThreadGoal, + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, ProjectScript as ContractProjectScript, - ThreadId, - ProjectId, - TurnId, - MessageId, - ProviderDriverKind, - ProviderInstanceId, - CheckpointRef, ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; +import type { + EnvironmentProject, + EnvironmentThread, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -34,142 +31,27 @@ export interface ThreadTerminalGroup { splitDirection?: "horizontal" | "vertical"; } -export interface ChatImageAttachment { - type: "image"; - id: string; - name: string; - mimeType: string; - sizeBytes: number; - previewUrl?: string; +export interface ChatImageAttachment extends ContractChatImageAttachment { + readonly previewUrl?: string; } export type ChatAttachment = ChatImageAttachment; -export interface ChatMessage { - id: MessageId; - role: "user" | "assistant" | "system"; - text: string; - attachments?: ChatAttachment[]; - turnId?: TurnId | null; - createdAt: string; - completedAt?: string | undefined; - streaming: boolean; +export interface ChatMessage extends Omit { + readonly attachments?: ReadonlyArray | undefined; } -export interface ProposedPlan { - id: OrchestrationProposedPlanId; - turnId: TurnId | null; - planMarkdown: string; - implementedAt: string | null; - implementationThreadId: ThreadId | null; - createdAt: string; - updatedAt: string; -} +export type ProposedPlan = OrchestrationProposedPlan; +export type TurnDiffFileChange = OrchestrationCheckpointFile; +export type TurnDiffSummary = OrchestrationCheckpointSummary; -export interface TurnDiffFileChange { - path: string; - kind?: string | undefined; - additions?: number | undefined; - deletions?: number | undefined; -} - -export interface TurnDiffSummary { - turnId: TurnId; - completedAt: string; - status?: string | undefined; - files: TurnDiffFileChange[]; - checkpointRef?: CheckpointRef | undefined; - assistantMessageId?: MessageId | undefined; - checkpointTurnCount?: number | undefined; -} - -export interface Project { - id: ProjectId; - environmentId: EnvironmentId; - name: string; - cwd: string; - repositoryIdentity?: RepositoryIdentity | null; - defaultModelSelection: ModelSelection | null; - createdAt?: string | undefined; - updatedAt?: string | undefined; - scripts: ProjectScript[]; -} - -export interface Thread { - id: ThreadId; - environmentId: EnvironmentId; - codexThreadId: string | null; - projectId: ProjectId; - title: string; - modelSelection: ModelSelection; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - session: ThreadSession | null; - messages: ChatMessage[]; - proposedPlans: ProposedPlan[]; - error: string | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - latestTurn: OrchestrationLatestTurn | null; - pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; - branch: string | null; - worktreePath: string | null; - turnDiffSummaries: TurnDiffSummary[]; - activities: OrchestrationThreadActivity[]; - goal: OrchestrationThreadGoal | null; -} - -export interface ThreadShell { - id: ThreadId; - environmentId: EnvironmentId; - codexThreadId: string | null; - projectId: ProjectId; - title: string; - modelSelection: ModelSelection; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - error: string | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - branch: string | null; - worktreePath: string | null; - goal: OrchestrationThreadGoal | null; -} +export type Project = EnvironmentProject; +export type Thread = EnvironmentThread; +export type ThreadShell = EnvironmentThreadShell; export interface ThreadTurnState { latestTurn: OrchestrationLatestTurn | null; - pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; } -export interface SidebarThreadSummary { - id: ThreadId; - environmentId: EnvironmentId; - projectId: ProjectId; - title: string; - interactionMode: ProviderInteractionMode; - session: ThreadSession | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - latestTurn: OrchestrationLatestTurn | null; - branch: string | null; - worktreePath: string | null; - latestUserMessageAt: string | null; - hasPendingApprovals: boolean; - hasPendingUserInput: boolean; - hasActionableProposedPlan: boolean; - goal: OrchestrationThreadGoal | null; -} - -export interface ThreadSession { - provider: ProviderDriverKind; - providerInstanceId?: ProviderInstanceId | undefined; - status: SessionPhase | "error" | "closed"; - activeTurnId?: TurnId | undefined; - createdAt: string; - updatedAt: string; - lastError?: string; - orchestrationStatus: OrchestrationSessionStatus; -} +export type SidebarThreadSummary = EnvironmentThreadShell; +export type ThreadSession = OrchestrationSession; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index c6f445b0c32..0fbbd79ec27 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -2,19 +2,18 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { - clearThreadUi, - hydratePersistedProjectState, - markThreadVisited, + legacyProjectCwdPreferenceKey, markThreadUnread, + markThreadVisited, + parsePersistedState, PERSISTED_STATE_KEY, type PersistedUiState, persistState, reorderProjects, + resolveProjectExpanded, setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, - syncProjects, - syncThreads, type UiState, } from "./uiStateStore"; @@ -30,418 +29,187 @@ function makeUiState(overrides: Partial = {}): UiState { } describe("uiStateStore pure functions", () => { - it("markThreadVisited stores the provided server timestamp", () => { + it("stores server timestamps without moving visit state backwards", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState(); + const visited = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z"); - const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z"); - - expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z"); - }); - - it("markThreadVisited does not move visit state backwards under clock skew", () => { - const threadId = ThreadId.make("thread-1"); - const initialState = makeUiState({ - threadLastVisitedAtById: { - [threadId]: "2026-02-25T12:30:00.700Z", - }, - }); - - const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.000Z"); - - expect(next).toBe(initialState); + expect(visited.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z"); + expect(markThreadVisited(visited, threadId, "2026-02-25T12:30:00.000Z")).toBe(visited); + expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited); }); - it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { + it("marks a completed thread unread using the server completion timestamp", () => { const threadId = ThreadId.make("thread-1"); - const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; const initialState = makeUiState({ threadLastVisitedAtById: { [threadId]: "2026-02-25T12:35:00.000Z", }, }); - const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt); + const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z"); expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); + expect(markThreadUnread(next, threadId, null)).toBe(next); }); - it("markThreadUnread does not change a thread without a completed turn", () => { - const threadId = ThreadId.make("thread-1"); - const initialState = makeUiState({ - threadLastVisitedAtById: { - [threadId]: "2026-02-25T12:35:00.000Z", - }, - }); + it("resolves project expansion from logical, physical, and legacy preference keys", () => { + const physicalKey = "environment:/repo/project"; + const legacyKey = legacyProjectCwdPreferenceKey("/repo/project"); - const next = markThreadUnread(initialState, threadId, null); - - expect(next).toBe(initialState); + expect(resolveProjectExpanded({ logical: false, [physicalKey]: true }, ["logical"])).toBe( + false, + ); + expect(resolveProjectExpanded({ [physicalKey]: false }, ["new-logical", physicalKey])).toBe( + false, + ); + expect(resolveProjectExpanded({ [legacyKey]: false }, ["new-logical", legacyKey])).toBe(false); + expect(resolveProjectExpanded({}, ["new-logical"])).toBe(true); }); - it("reorderProjects moves a project to a target index", () => { - const project1 = ProjectId.make("project-1"); - const project2 = ProjectId.make("project-2"); - const project3 = ProjectId.make("project-3"); - const initialState = makeUiState({ - projectOrder: [project1, project2, project3], - }); + it("sets expansion for every stable key belonging to a logical project", () => { + const initialState = makeUiState(); + const keys = ["logical", "environment-a:/repo", "environment-b:/repo"]; - const next = reorderProjects(initialState, [project1], [project3]); + const next = setProjectExpanded(initialState, keys, false); - expect(next.projectOrder).toEqual([project2, project3, project1]); + expect(next.projectExpandedById).toEqual({ + logical: false, + "environment-a:/repo": false, + "environment-b:/repo": false, + }); + expect(setProjectExpanded(next, keys, false)).toBe(next); }); - it("reorderProjects is a no-op when dragged key is not in projectOrder", () => { + it("reorders from the current atom-derived project order", () => { const project1 = ProjectId.make("project-1"); const project2 = ProjectId.make("project-2"); - const initialState = makeUiState({ - projectOrder: [project1, project2], - }); - - const next = reorderProjects(initialState, [ProjectId.make("missing")], [project2]); - - expect(next).toBe(initialState); - }); - - it("setDefaultAdvertisedEndpointKey stores endpoint preference by stable key", () => { - const initialState = makeUiState(); + const project3 = ProjectId.make("project-3"); + const currentOrder = [project1, project2, project3]; - const next = setDefaultAdvertisedEndpointKey(initialState, "desktop-core:lan:http"); + const next = reorderProjects(makeUiState(), currentOrder, [project1], [project3]); - expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); - expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next); - expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({ - defaultAdvertisedEndpointKey: null, - }); + expect(next.projectOrder).toEqual([project2, project3, project1]); }); - it("reorderProjects moves all member keys of a multi-member group together", () => { + it("moves grouped project members together", () => { const keyALocal = "env-local:proj-a"; const keyARemote = "env-remote:proj-a"; const keyB = "env-local:proj-b"; const keyC = "env-local:proj-c"; - const initialState = makeUiState({ - projectOrder: [keyALocal, keyARemote, keyB, keyC], - }); - - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); - - expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); - }); - - it("reorderProjects handles member keys scattered across projectOrder", () => { - const keyALocal = "env-local:proj-a"; - const keyB = "env-local:proj-b"; - const keyARemote = "env-remote:proj-a"; - const keyC = "env-local:proj-c"; - const initialState = makeUiState({ - projectOrder: [keyALocal, keyB, keyARemote, keyC], - }); + const currentOrder = [keyALocal, keyARemote, keyB, keyC]; - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); + const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]); expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); }); - it("reorderProjects places group after target when dragged from before a non-last target", () => { - const keyALocal = "env-local:proj-a"; - const keyARemote = "env-remote:proj-a"; - const keyB = "env-local:proj-b"; - const keyC = "env-local:proj-c"; - const keyD = "env-local:proj-d"; - const initialState = makeUiState({ - projectOrder: [keyALocal, keyARemote, keyB, keyC, keyD], - }); - - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); + it("does not reorder missing or identical groups", () => { + const currentOrder = ["env-local:proj-a", "env-local:proj-b"]; + const state = makeUiState(); - expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote, keyD]); + expect(reorderProjects(state, currentOrder, ["env-local:missing"], ["env-local:proj-b"])).toBe( + state, + ); + expect(reorderProjects(state, currentOrder, ["env-local:proj-a"], ["env-local:proj-a"])).toBe( + state, + ); }); - it("reorderProjects places group before target when dragged from after", () => { - const keyB = "env-local:proj-b"; - const keyC = "env-local:proj-c"; - const keyALocal = "env-local:proj-a"; - const keyARemote = "env-remote:proj-a"; - const initialState = makeUiState({ - projectOrder: [keyB, keyC, keyALocal, keyARemote], - }); - - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyB]); - - expect(next.projectOrder).toEqual([keyALocal, keyARemote, keyB, keyC]); - }); + it("stores only collapsed changed-file turns", () => { + const threadId = ThreadId.make("thread-1"); + const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); - it("reorderProjects with multi-member target inserts after first target occurrence", () => { - const keyALocal = "env-local:proj-a"; - const keyARemote = "env-remote:proj-a"; - const keyBLocal = "env-local:proj-b"; - const keyBRemote = "env-remote:proj-b"; - const initialState = makeUiState({ - projectOrder: [keyALocal, keyARemote, keyBLocal, keyBRemote], + expect(collapsed.threadChangedFilesExpandedById).toEqual({ + [threadId]: { + "turn-1": false, + }, }); - - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]); - - // Target members may become non-contiguous; this is fine because the - // sidebar groups by logical key using first-occurrence positioning. - expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]); + expect( + setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) + .threadChangedFilesExpandedById, + ).toEqual({}); }); - it("reorderProjects is a no-op when dragged group equals target group", () => { - const key1 = "env-local:proj-a"; - const key2 = "env-remote:proj-a"; - const initialState = makeUiState({ - projectOrder: [key1, key2, "env-local:proj-b"], - }); - - const next = reorderProjects(initialState, [key1, key2], [key1, key2]); + it("stores the endpoint preference by stable key", () => { + const next = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); - expect(next).toBe(initialState); - }); - - it("reorderProjects is a no-op when dragged keys are not in projectOrder", () => { - const initialState = makeUiState({ - projectOrder: ["env-local:proj-a", "env-local:proj-b"], + expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); + expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next); + expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({ + defaultAdvertisedEndpointKey: null, }); - - const next = reorderProjects(initialState, ["env-local:missing"], ["env-local:proj-b"]); - - expect(next).toBe(initialState); }); +}); - it("syncProjects preserves current project order during snapshot recovery", () => { - const project1 = ProjectId.make("project-1"); - const project2 = ProjectId.make("project-2"); - const project3 = ProjectId.make("project-3"); - const initialState = makeUiState({ +describe("parsePersistedState", () => { + it("hydrates raw UI-owned state without server entities", () => { + const parsed = parsePersistedState({ projectExpandedById: { - [project1]: true, - [project2]: false, + logical: false, + invalid: "no" as unknown as boolean, }, - projectOrder: [project2, project1], - }); - - const next = syncProjects(initialState, [ - { key: project1, logicalKey: project1, cwd: "/tmp/project-1" }, - { key: project2, logicalKey: project2, cwd: "/tmp/project-2" }, - { key: project3, logicalKey: project3, cwd: "/tmp/project-3" }, - ]); - - expect(next.projectOrder).toEqual([project2, project1, project3]); - expect(next.projectExpandedById[project2]).toBe(false); - }); - - it("syncProjects preserves manual order across project id churn at the same cwd", () => { - // Under the current design, physical key and logical key are both - // cwd-derived, so an internal project-id change doesn't alter the store - // keys. This test locks in that stability: re-syncing the same cwds keeps - // manual order and collapse state. - const keyProject1 = "env-local:/tmp/project-1"; - const keyProject2 = "env-local:/tmp/project-2"; - const initialState = syncProjects( - makeUiState({ - projectExpandedById: { - [keyProject1]: true, - [keyProject2]: false, - }, - projectOrder: [keyProject2, keyProject1], - }), - [ - { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, - { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, - ], - ); - - const next = syncProjects(initialState, [ - { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, - { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, - ]); - - expect(next.projectOrder).toEqual([keyProject2, keyProject1]); - expect(next.projectExpandedById[keyProject2]).toBe(false); - }); - - it("syncProjects returns a new state when only project cwd changes", () => { - const project1 = ProjectId.make("project-1"); - const initialState = syncProjects( - makeUiState({ - projectExpandedById: { - [project1]: false, - }, - projectOrder: [project1], - }), - [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }], - ); - - const next = syncProjects(initialState, [ - { key: project1, logicalKey: project1, cwd: "/tmp/project-1-renamed" }, - ]); - - expect(next).not.toBe(initialState); - expect(next.projectOrder).toEqual([project1]); - expect(next.projectExpandedById[project1]).toBe(false); - }); - - it("syncProjects keys projectExpandedById by the logical key, not the physical key", () => { - // In repository grouping mode, multiple physical projects (different - // environments or different repo-relative paths) collapse into one - // logical group. The group's expand state must be keyed by the logical - // key so clicks on the grouped row toggle the shared state, and so the - // state survives subsequent syncProjects calls (which rebuild the map - // from incoming inputs). - const physicalLocal = "env-local:/repo/project"; - const physicalRemote = "env-remote:/repo/project"; - const logicalKey = "repo-canonical-key"; - - const initial = syncProjects(makeUiState(), [ - { key: physicalLocal, logicalKey, cwd: "/repo/project" }, - { key: physicalRemote, logicalKey, cwd: "/repo/project" }, - ]); - - expect(initial.projectExpandedById).toEqual({ [logicalKey]: true }); - - const afterCollapse = { ...initial, projectExpandedById: { [logicalKey]: false } }; - const next = syncProjects(afterCollapse, [ - { key: physicalLocal, logicalKey, cwd: "/repo/project" }, - { key: physicalRemote, logicalKey, cwd: "/repo/project" }, - ]); - - expect(next.projectExpandedById[logicalKey]).toBe(false); - }); - - it("syncProjects preserves expand state when a project's logical key changes", () => { - // Example: late-arriving repo metadata flips grouping identity from the - // physical key to a canonical repository key. The row did not actually - // change, so the user's collapse choice must carry over. - const physicalKey = "env-local:/repo/project"; - const previousLogicalKey = physicalKey; - const nextLogicalKey = "repo-canonical-key"; - - const initial = syncProjects(makeUiState(), [ - { key: physicalKey, logicalKey: previousLogicalKey, cwd: "/repo/project" }, - ]); - - expect(initial.projectExpandedById[previousLogicalKey]).toBe(true); - - const afterCollapse = { - ...initial, - projectExpandedById: { [previousLogicalKey]: false }, - }; - const next = syncProjects(afterCollapse, [ - { key: physicalKey, logicalKey: nextLogicalKey, cwd: "/repo/project" }, - ]); - - expect(next.projectExpandedById[nextLogicalKey]).toBe(false); - }); - - it("syncThreads prunes missing thread UI state", () => { - const thread1 = ThreadId.make("thread-1"); - const thread2 = ThreadId.make("thread-2"); - const initialState = makeUiState({ + projectOrder: ["physical-b", "", "physical-a", "physical-b"], threadLastVisitedAtById: { - [thread1]: "2026-02-25T12:35:00.000Z", - [thread2]: "2026-02-25T12:36:00.000Z", + "environment:thread-1": "2026-02-25T12:35:00.000Z", + invalid: "not-a-date", }, + defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { - [thread1]: { + "environment:thread-1": { "turn-1": false, + "turn-2": true, }, - [thread2]: { - "turn-2": false, - }, - }, - }); - - const next = syncThreads(initialState, [{ key: thread1 }]); - - expect(next.threadLastVisitedAtById).toEqual({ - [thread1]: "2026-02-25T12:35:00.000Z", - }); - expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: { - "turn-1": false, - }, - }); - }); - - it("syncThreads seeds visit state for unseen snapshot threads", () => { - const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState(); - - const next = syncThreads(initialState, [ - { - key: thread1, - seedVisitedAt: "2026-02-25T12:35:00.000Z", }, - ]); - - expect(next.threadLastVisitedAtById).toEqual({ - [thread1]: "2026-02-25T12:35:00.000Z", }); - }); - it("setProjectExpanded updates expansion without touching order", () => { - const project1 = ProjectId.make("project-1"); - const initialState = makeUiState({ + expect(parsed).toEqual({ projectExpandedById: { - [project1]: true, + logical: false, }, - projectOrder: [project1], - }); - - const next = setProjectExpanded(initialState, project1, false); - - expect(next.projectExpandedById[project1]).toBe(false); - expect(next.projectOrder).toEqual([project1]); - }); - - it("clearThreadUi removes visit state for deleted threads", () => { - const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState({ + projectOrder: ["physical-b", "physical-a"], threadLastVisitedAtById: { - [thread1]: "2026-02-25T12:35:00.000Z", + "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { - [thread1]: { + "environment:thread-1": { "turn-1": false, }, }, }); - - const next = clearThreadUi(initialState, thread1); - - expect(next.threadLastVisitedAtById).toEqual({}); - expect(next.threadChangedFilesExpandedById).toEqual({}); }); - it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { - const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState(); - - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); - - expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: { - "turn-1": false, - }, + it("migrates legacy CWD project preferences into local alias keys", () => { + const parsed = parsePersistedState({ + collapsedProjectCwds: ["/repo/b"], + expandedProjectCwds: ["/repo/a"], + projectOrderCwds: ["/repo/b", "/repo/a"], }); + const projectAKey = legacyProjectCwdPreferenceKey("/repo/a"); + const projectBKey = legacyProjectCwdPreferenceKey("/repo/b"); + + expect(parsed.projectOrder).toEqual([projectBKey, projectAKey]); + expect(resolveProjectExpanded(parsed.projectExpandedById, [projectAKey])).toBe(true); + expect(resolveProjectExpanded(parsed.projectExpandedById, [projectBKey])).toBe(false); + expect(resolveProjectExpanded(parsed.projectExpandedById, ["unknown"])).toBe(true); }); - it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { - const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState({ - threadChangedFilesExpandedById: { - [thread1]: { - "turn-1": false, - }, - }, + it("preserves legacy expanded-only semantics for one-way migration", () => { + const parsed = parsePersistedState({ + expandedProjectCwds: ["/repo/a"], }); - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true); - - expect(next.threadChangedFilesExpandedById).toEqual({}); + expect( + resolveProjectExpanded(parsed.projectExpandedById, [ + legacyProjectCwdPreferenceKey("/repo/a"), + ]), + ).toBe(true); + expect( + resolveProjectExpanded(parsed.projectExpandedById, [ + legacyProjectCwdPreferenceKey("/repo/b"), + ]), + ).toBe(false); }); }); @@ -465,146 +233,77 @@ function createLocalStorageStub(): Storage { }; } -describe("uiStateStore persistence round-trip", () => { +describe("uiStateStore persistence", () => { let localStorageStub: Storage; beforeEach(() => { localStorageStub = createLocalStorageStub(); vi.stubGlobal("window", { localStorage: localStorageStub }); vi.stubGlobal("localStorage", localStorageStub); - // Reset module-level persistence state so tests don't bleed into each other. - hydratePersistedProjectState({ collapsedProjectCwds: [], expandedProjectCwds: [] }); }); afterEach(() => { vi.unstubAllGlobals(); }); - it("preserves all-collapsed project state across restart", () => { - // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so - // an empty array on rehydrate was indistinguishable from a fresh install - // and the syncProjects fallback re-expanded every row. - const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; - const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; - - let state = syncProjects(makeUiState(), [projectA, projectB]); - state = setProjectExpanded(state, projectA.key, false); - state = setProjectExpanded(state, projectB.key, false); - persistState(state); - - const persisted = JSON.parse( - localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", - ) as PersistedUiState; - hydratePersistedProjectState(persisted); - const rehydrated = syncProjects(makeUiState(), [projectA, projectB]); - - expect(rehydrated.projectExpandedById).toEqual({ - [projectA.key]: false, - [projectB.key]: false, + it("persists raw UI preferences including thread visit markers", () => { + const state = makeUiState({ + projectExpandedById: { + logical: false, + }, + projectOrder: ["physical-b", "physical-a"], + threadLastVisitedAtById: { + "environment:thread-1": "2026-02-25T12:35:00.000Z", + }, + threadChangedFilesExpandedById: { + "environment:thread-1": { + "turn-1": false, + "turn-2": true, + }, + }, + defaultAdvertisedEndpointKey: "desktop-core:lan:http", }); - }); - - it("respects mixed expand state on rehydrate and defaults new projects to expanded", () => { - const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; - const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; - const projectC = { key: "kC", logicalKey: "kC", cwd: "/projC" }; - let state = syncProjects(makeUiState(), [projectA, projectB]); - state = setProjectExpanded(state, projectB.key, false); persistState(state); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; - hydratePersistedProjectState(persisted); - const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); - - expect(rehydrated.projectExpandedById).toEqual({ - [projectA.key]: true, - [projectB.key]: false, - [projectC.key]: true, - }); - }); - - it("preserves legacy not-in-expanded-list = collapsed for one upgrade session", () => { - // Pre-fix shape only stored expandedProjectCwds. Absence of - // collapsedProjectCwds opts the session into the legacy fallback so - // upgrade users do not see previously collapsed rows pop open. - hydratePersistedProjectState({ - expandedProjectCwds: ["/projA"], + expect(persisted).toEqual({ + projectExpandedById: { + logical: false, + }, + projectOrder: ["physical-b", "physical-a"], + threadLastVisitedAtById: { + "environment:thread-1": "2026-02-25T12:35:00.000Z", + }, + defaultAdvertisedEndpointKey: "desktop-core:lan:http", + threadChangedFilesExpandedById: { + "environment:thread-1": { + "turn-1": false, + }, + }, }); - - const rehydrated = syncProjects(makeUiState(), [ - { key: "kA", logicalKey: "kA", cwd: "/projA" }, - { key: "kB", logicalKey: "kB", cwd: "/projB" }, - ]); - - expect(rehydrated.projectExpandedById).toEqual({ - kA: true, - kB: false, + expect(parsePersistedState(persisted)).toEqual({ + ...state, + threadChangedFilesExpandedById: { + "environment:thread-1": { + "turn-1": false, + }, + }, }); }); - it("preserves manual project order across restart", () => { - const projectA = { key: "kOrderA", logicalKey: "kOrderA", cwd: "/order-projA" }; - const projectB = { key: "kOrderB", logicalKey: "kOrderB", cwd: "/order-projB" }; - const projectC = { key: "kOrderC", logicalKey: "kOrderC", cwd: "/order-projC" }; - - let state = syncProjects(makeUiState(), [projectA, projectB, projectC]); - state = reorderProjects(state, [projectC.key], [projectA.key]); - expect(state.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); - persistState(state); - - const persisted = JSON.parse( - localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", - ) as PersistedUiState; - expect(persisted.projectOrderCwds).toEqual([projectC.cwd, projectA.cwd, projectB.cwd]); - - hydratePersistedProjectState(persisted); - // Fresh state (empty projectOrder) so syncProjects derives order from - // persistedProjectOrderCwds rather than the in-memory projectOrder branch. - const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); - - expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); - }); - - it("persists the default advertised endpoint preference", () => { - const state = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); - - persistState(state); - - const persisted = JSON.parse( - localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", - ) as PersistedUiState; - expect(persisted.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); - }); + it("drops the temporary expanded-only migration fallback when rewriting state", () => { + const migrated = parsePersistedState({ + expandedProjectCwds: ["/repo/a"], + }); - it("preserves expand state across restart when project's logical key changes", () => { - // After restart, in-memory previousExpandedById is empty, so the - // previousLogicalKey-to-state bridge in syncProjects cannot help. The - // persisted-cwd fallback is the only mechanism that can carry collapse - // state across a restart that also flips a project into a new logical - // group (e.g. late-arriving repo metadata). This locks in that path. - const physicalKey = "env-local:/lk-restart-proj"; - const previousLogicalKey = physicalKey; - const cwd = "/lk-restart-proj"; - - let state = syncProjects(makeUiState(), [ - { key: physicalKey, logicalKey: previousLogicalKey, cwd }, - ]); - state = setProjectExpanded(state, previousLogicalKey, false); - persistState(state); + persistState(migrated); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; - hydratePersistedProjectState(persisted); - - const nextLogicalKey = "lk-restart-canonical"; - const rehydrated = syncProjects(makeUiState(), [ - { key: physicalKey, logicalKey: nextLogicalKey, cwd }, - ]); - - expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false); + expect(resolveProjectExpanded(persisted.projectExpandedById ?? {}, ["unknown"])).toBe(true); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index f16495bed7f..4a97f0542b4 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,5 +1,6 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; +import { normalizeProjectPathForComparison } from "./lib/projectPaths"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ @@ -16,6 +17,9 @@ const LEGACY_PERSISTED_STATE_KEYS = [ ] as const; export interface PersistedUiState { + projectExpandedById?: Record; + projectOrder?: string[]; + threadLastVisitedAtById?: Record; collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; @@ -39,19 +43,6 @@ export interface UiEndpointState { export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} -export interface SyncProjectInput { - /** Physical project key (env + cwd). Used for manual sort order. */ - key: string; - /** Logical group key. Used for expand/collapse state. */ - logicalKey: string; - cwd: string; -} - -export interface SyncThreadInput { - key: string; - seedVisitedAt?: string | undefined; -} - const initialState: UiState = { projectExpandedById: {}, projectOrder: [], @@ -60,20 +51,90 @@ const initialState: UiState = { defaultAdvertisedEndpointKey: null, }; -const persistedCollapsedProjectCwds = new Set(); -const persistedExpandedProjectCwds = new Set(); -const persistedProjectOrderCwds: string[] = []; -const persistedProjectOrderCwdSet = new Set(); -// Pre-fix persisted shape only listed expanded cwds, so anything not listed -// was treated as collapsed. Track whether the loaded blob carried the new -// `collapsedProjectCwds` field so we can preserve that legacy semantic for -// one session after upgrade, until persistState rewrites in the new shape. -let persistedProjectStateUsesLegacyShape = false; -const currentProjectCwdById = new Map(); -const currentProjectCwdsByLogicalKey = new Map(); -const currentLogicalKeyByPhysicalKey = new Map(); +const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; +const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default"; let legacyKeysCleanedUp = false; +export function legacyProjectCwdPreferenceKey(cwd: string): string { + return `${LEGACY_PROJECT_CWD_PREFERENCE_PREFIX}${normalizeProjectPathForComparison(cwd)}`; +} + +function sanitizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return [ + ...new Set( + value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0), + ), + ]; +} + +function sanitizeBooleanRecord(value: unknown): Record { + if (!value || typeof value !== "object") { + return {}; + } + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, boolean] => entry[0].length > 0 && typeof entry[1] === "boolean", + ), + ); +} + +function sanitizeTimestampRecord(value: unknown): Record { + if (!value || typeof value !== "object") { + return {}; + } + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string] => + entry[0].length > 0 && + typeof entry[1] === "string" && + entry[1].length > 0 && + Number.isFinite(Date.parse(entry[1])), + ), + ); +} + +export function parsePersistedState(parsed: PersistedUiState): UiState { + const projectExpandedById = + parsed.projectExpandedById === undefined + ? (() => { + const migrated: Record = {}; + const collapsedProjectCwds = sanitizeStringArray(parsed.collapsedProjectCwds); + const expandedProjectCwds = sanitizeStringArray(parsed.expandedProjectCwds); + for (const cwd of collapsedProjectCwds) { + migrated[legacyProjectCwdPreferenceKey(cwd)] = false; + } + for (const cwd of expandedProjectCwds) { + migrated[legacyProjectCwdPreferenceKey(cwd)] = true; + } + if (!Array.isArray(parsed.collapsedProjectCwds) && expandedProjectCwds.length > 0) { + migrated[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] = false; + } + return migrated; + })() + : sanitizeBooleanRecord(parsed.projectExpandedById); + const projectOrder = + parsed.projectOrder === undefined + ? sanitizeStringArray(parsed.projectOrderCwds).map(legacyProjectCwdPreferenceKey) + : sanitizeStringArray(parsed.projectOrder); + + return { + projectExpandedById, + projectOrder, + threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), + threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( + parsed.threadChangedFilesExpandedById, + ), + defaultAdvertisedEndpointKey: + typeof parsed.defaultAdvertisedEndpointKey === "string" && + parsed.defaultAdvertisedEndpointKey.length > 0 + ? parsed.defaultAdvertisedEndpointKey + : null, + }; +} + function readPersistedState(): UiState { if (typeof window === "undefined") { return initialState; @@ -86,24 +147,11 @@ function readPersistedState(): UiState { if (!legacyRaw) { continue; } - hydratePersistedProjectState(JSON.parse(legacyRaw) as PersistedUiState); - return initialState; + return parsePersistedState(JSON.parse(legacyRaw) as PersistedUiState); } return initialState; } - const parsed = JSON.parse(raw) as PersistedUiState; - hydratePersistedProjectState(parsed); - return { - ...initialState, - defaultAdvertisedEndpointKey: - typeof parsed.defaultAdvertisedEndpointKey === "string" && - parsed.defaultAdvertisedEndpointKey.length > 0 - ? parsed.defaultAdvertisedEndpointKey - : null, - threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( - parsed.threadChangedFilesExpandedById, - ), - }; + return parsePersistedState(JSON.parse(raw) as PersistedUiState); } catch { return initialState; } @@ -137,48 +185,16 @@ function sanitizePersistedThreadChangedFilesExpanded( return nextState; } -export function hydratePersistedProjectState(parsed: PersistedUiState): void { - persistedCollapsedProjectCwds.clear(); - persistedExpandedProjectCwds.clear(); - persistedProjectOrderCwds.length = 0; - persistedProjectOrderCwdSet.clear(); - persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds); - for (const cwd of parsed.collapsedProjectCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0) { - persistedCollapsedProjectCwds.add(cwd); - } - } - for (const cwd of parsed.expandedProjectCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0) { - persistedExpandedProjectCwds.add(cwd); - } - } - for (const cwd of parsed.projectOrderCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwdSet.has(cwd)) { - persistedProjectOrderCwdSet.add(cwd); - persistedProjectOrderCwds.push(cwd); - } - } -} - export function persistState(state: UiState): void { if (typeof window === "undefined") { return; } try { - // Persist collapsed cwds explicitly so an empty/missing field unambiguously - // means "first install" rather than "user collapsed everything"; without - // this, the syncProjects fallback would re-expand all rows on next launch. - const collapsedProjectCwds = Object.entries(state.projectExpandedById) - .filter(([, expanded]) => !expanded) - .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); - const expandedProjectCwds = Object.entries(state.projectExpandedById) - .filter(([, expanded]) => expanded) - .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); - const projectOrderCwds = state.projectOrder.flatMap((projectId) => { - const cwd = currentProjectCwdById.get(projectId); - return cwd ? [cwd] : []; - }); + const projectExpandedById = Object.fromEntries( + Object.entries(state.projectExpandedById).filter( + ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, + ), + ); const threadChangedFilesExpandedById = Object.fromEntries( Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { const nextTurns = Object.fromEntries( @@ -190,9 +206,9 @@ export function persistState(state: UiState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ - collapsedProjectCwds, - expandedProjectCwds, - projectOrderCwds, + projectExpandedById, + projectOrder: state.projectOrder, + threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, } satisfies PersistedUiState), @@ -210,242 +226,11 @@ export function persistState(state: UiState): void { const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); -function recordsEqual(left: Record, right: Record): boolean { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) { - return false; - } - for (const [key, value] of leftEntries) { - if (right[key] !== value) { - return false; - } - } - return true; -} - -function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean { - return ( - left.length === right.length && left.every((projectId, index) => projectId === right[index]) - ); -} - -function nestedBooleanRecordsEqual( - left: Record>, - right: Record>, -): boolean { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) { - return false; - } - for (const [key, value] of leftEntries) { - if (!(key in right) || !recordsEqual(value, right[key]!)) { - return false; - } - } - return true; -} - -export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { - const previousProjectCwdById = new Map(currentProjectCwdById); - const previousLogicalKeyByPhysicalKey = new Map(currentLogicalKeyByPhysicalKey); - currentProjectCwdById.clear(); - currentLogicalKeyByPhysicalKey.clear(); - for (const project of projects) { - currentProjectCwdById.set(project.key, project.cwd); - currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey); - } - currentProjectCwdsByLogicalKey.clear(); - const currentProjectCwdSetsByLogicalKey = new Map>(); - for (const project of projects) { - const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey); - if (cwds) { - let cwdSet = currentProjectCwdSetsByLogicalKey.get(project.logicalKey); - if (!cwdSet) { - cwdSet = new Set(cwds); - currentProjectCwdSetsByLogicalKey.set(project.logicalKey, cwdSet); - } - if (!cwdSet.has(project.cwd)) { - cwdSet.add(project.cwd); - cwds.push(project.cwd); - } - } else { - currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]); - currentProjectCwdSetsByLogicalKey.set(project.logicalKey, new Set([project.cwd])); - } - } - // Build reverse map: for each new logical key, which previous logical keys - // did its member projects live under? Lets us preserve expand state when a - // project's logical key changes (e.g. late-arriving repo metadata flips the - // group identity). - const previousLogicalKeysByNewLogicalKey = new Map>(); - for (const project of projects) { - const previousLogicalKey = previousLogicalKeyByPhysicalKey.get(project.key); - if (!previousLogicalKey || previousLogicalKey === project.logicalKey) { - continue; - } - const set = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); - if (set) { - set.add(previousLogicalKey); - } else { - previousLogicalKeysByNewLogicalKey.set(project.logicalKey, new Set([previousLogicalKey])); - } - } - const cwdMappingChanged = - previousProjectCwdById.size !== currentProjectCwdById.size || - projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); - - const nextExpandedById: Record = {}; - const previousExpandedById = state.projectExpandedById; - const persistedOrderByCwd = new Map( - persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), - ); - const mappedProjects = projects.map((project, index) => { - if (!(project.logicalKey in nextExpandedById)) { - const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd]; - const fallbackFromPreviousLogicalKey = (() => { - const previousKeys = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); - if (!previousKeys) { - return undefined; - } - for (const previousKey of previousKeys) { - if (previousKey in previousExpandedById) { - return previousExpandedById[previousKey]; - } - } - return undefined; - })(); - const fallbackFromPersistedShape = (() => { - if (groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd))) { - return true; - } - if (groupCwds.some((cwd) => persistedCollapsedProjectCwds.has(cwd))) { - return false; - } - if (persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0) { - return false; - } - return true; - })(); - const expanded = - previousExpandedById[project.logicalKey] ?? - fallbackFromPreviousLogicalKey ?? - fallbackFromPersistedShape; - nextExpandedById[project.logicalKey] = expanded; - } - return { - id: project.key, - cwd: project.cwd, - incomingIndex: index, - }; - }); - - const nextProjectOrder = - state.projectOrder.length > 0 - ? (() => { - const currentProjectIds = new Set(mappedProjects.map((project) => project.id)); - const nextProjectIdByCwd = new Map( - mappedProjects.map((project) => [project.cwd, project.id] as const), - ); - const usedProjectIds = new Set(); - const orderedProjectIds: string[] = []; - - for (const projectId of state.projectOrder) { - const matchedProjectId = - (currentProjectIds.has(projectId) ? projectId : undefined) ?? - (() => { - const previousCwd = previousProjectCwdById.get(projectId); - return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined; - })(); - if (!matchedProjectId || usedProjectIds.has(matchedProjectId)) { - continue; - } - usedProjectIds.add(matchedProjectId); - orderedProjectIds.push(matchedProjectId); - } - - for (const project of mappedProjects) { - if (usedProjectIds.has(project.id)) { - continue; - } - orderedProjectIds.push(project.id); - } - - return orderedProjectIds; - })() - : mappedProjects - .map((project) => ({ - id: project.id, - incomingIndex: project.incomingIndex, - orderIndex: - persistedOrderByCwd.get(project.cwd) ?? - persistedProjectOrderCwds.length + project.incomingIndex, - })) - .toSorted((left, right) => { - const byOrder = left.orderIndex - right.orderIndex; - if (byOrder !== 0) { - return byOrder; - } - return left.incomingIndex - right.incomingIndex; - }) - .map((project) => project.id); - - if ( - recordsEqual(state.projectExpandedById, nextExpandedById) && - projectOrdersEqual(state.projectOrder, nextProjectOrder) && - !cwdMappingChanged - ) { +export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState { + const visitedAtMs = Date.parse(visitedAt); + if (!Number.isFinite(visitedAtMs)) { return state; } - - return { - ...state, - projectExpandedById: nextExpandedById, - projectOrder: nextProjectOrder, - }; -} - -export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { - const retainedThreadIds = new Set(threads.map((thread) => thread.key)); - const nextThreadLastVisitedAtById = Object.fromEntries( - Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => - retainedThreadIds.has(threadId), - ), - ); - for (const thread of threads) { - if ( - nextThreadLastVisitedAtById[thread.key] === undefined && - thread.seedVisitedAt !== undefined && - thread.seedVisitedAt.length > 0 - ) { - nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; - } - } - const nextThreadChangedFilesExpandedById = Object.fromEntries( - Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) => - retainedThreadIds.has(threadId), - ), - ); - if ( - recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && - nestedBooleanRecordsEqual( - state.threadChangedFilesExpandedById, - nextThreadChangedFilesExpandedById, - ) - ) { - return state; - } - return { - ...state, - threadLastVisitedAtById: nextThreadLastVisitedAtById, - threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, - }; -} - -export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState { - const at = visitedAt ?? new Date().toISOString(); - const visitedAtMs = Date.parse(at); const previousVisitedAt = state.threadLastVisitedAtById[threadId]; const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN; if ( @@ -459,7 +244,7 @@ export function markThreadVisited(state: UiState, threadId: string, visitedAt?: ...state, threadLastVisitedAtById: { ...state.threadLastVisitedAtById, - [threadId]: at, + [threadId]: visitedAt, }, }; } @@ -489,23 +274,6 @@ export function markThreadUnread( }; } -export function clearThreadUi(state: UiState, threadId: string): UiState { - const hasVisitedState = threadId in state.threadLastVisitedAtById; - const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById; - if (!hasVisitedState && !hasChangedFilesState) { - return state; - } - const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; - const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; - delete nextThreadLastVisitedAtById[threadId]; - delete nextThreadChangedFilesExpandedById[threadId]; - return { - ...state, - threadLastVisitedAtById: nextThreadLastVisitedAtById, - threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, - }; -} - export function setThreadChangedFilesExpanded( state: UiState, threadId: string, @@ -566,32 +334,42 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } -export function toggleProject(state: UiState, projectId: string): UiState { - const expanded = state.projectExpandedById[projectId] ?? true; - return { - ...state, - projectExpandedById: { - ...state.projectExpandedById, - [projectId]: !expanded, - }, - }; +export function resolveProjectExpanded( + projectExpandedById: Readonly>, + preferenceKeys: readonly string[], +): boolean { + for (const key of preferenceKeys) { + const expanded = projectExpandedById[key]; + if (expanded !== undefined) { + return expanded; + } + } + return projectExpandedById[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] ?? true; } -export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { - if ((state.projectExpandedById[projectId] ?? true) === expanded) { +export function setProjectExpanded( + state: UiState, + projectIds: string | readonly string[], + expanded: boolean, +): UiState { + const ids = typeof projectIds === "string" ? [projectIds] : projectIds; + const nextEntries = ids.filter((projectId) => state.projectExpandedById[projectId] !== expanded); + if (nextEntries.length === 0) { return state; } + const projectExpandedById = { ...state.projectExpandedById }; + for (const projectId of nextEntries) { + projectExpandedById[projectId] = expanded; + } return { ...state, - projectExpandedById: { - ...state.projectExpandedById, - [projectId]: expanded, - }, + projectExpandedById, }; } export function reorderProjects( state: UiState, + currentProjectOrder: readonly string[], draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ): UiState { @@ -604,12 +382,12 @@ export function reorderProjects( return state; } - const originalTargetIndex = state.projectOrder.findIndex((id) => targetSet.has(id)); + const originalTargetIndex = currentProjectOrder.findIndex((id) => targetSet.has(id)); if (originalTargetIndex < 0) { return state; } - const projectOrder = [...state.projectOrder]; + const projectOrder = [...currentProjectOrder]; const removed: string[] = []; let draggedBeforeTarget = 0; @@ -634,16 +412,13 @@ export function reorderProjects( } interface UiStateStore extends UiState { - syncProjects: (projects: readonly SyncProjectInput[]) => void; - syncThreads: (threads: readonly SyncThreadInput[]) => void; - markThreadVisited: (threadId: string, visitedAt?: string) => void; + markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; - clearThreadUi: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; - toggleProject: (projectId: string) => void; - setProjectExpanded: (projectId: string, expanded: boolean) => void; + setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( + currentProjectOrder: readonly string[], draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ) => void; @@ -651,22 +426,20 @@ interface UiStateStore extends UiState { export const useUiStateStore = create((set) => ({ ...readPersistedState(), - syncProjects: (projects) => set((state) => syncProjects(state, projects)), - syncThreads: (threads) => set((state) => syncThreads(state, threads)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), - clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), - toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), - setProjectExpanded: (projectId, expanded) => - set((state) => setProjectExpanded(state, projectId, expanded)), - reorderProjects: (draggedProjectIds, targetProjectIds) => - set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), + setProjectExpanded: (projectIds, expanded) => + set((state) => setProjectExpanded(state, projectIds, expanded)), + reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => + set((state) => + reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds), + ), })); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index cb0116c8550..88691cfc25e 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -64,7 +64,8 @@ function readVersionMismatchDismissals(): VersionMismatchDismissals { VersionMismatchDismissalsSchema, ) ?? { keys: [] } ); - } catch { + } catch (error) { + console.error("Could not read version-mismatch dismissals.", error); return { keys: [] }; } } @@ -76,8 +77,8 @@ function writeVersionMismatchDismissals(document: VersionMismatchDismissals): vo document, VersionMismatchDismissalsSchema, ); - } catch { - // Dismissal state is best-effort UI state; a storage failure should not block the banner. + } catch (error) { + console.error("Could not persist version-mismatch dismissals.", error); } } diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts new file mode 100644 index 00000000000..b481221e63a --- /dev/null +++ b/apps/web/src/workspaceTitlebar.ts @@ -0,0 +1,2 @@ +export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = + "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 0975a371204..1430e15beb0 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -10,7 +10,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: localEnvironmentId, - codexThreadId: null, projectId: ProjectId.make("project-1"), title: "Thread", modelSelection: { @@ -21,12 +20,13 @@ function makeThread(overrides: Partial = {}): Thread { interactionMode: DEFAULT_INTERACTION_MODE, session: null, messages: [], - turnDiffSummaries: [], + checkpoints: [], activities: [], proposedPlans: [], - error: null, createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + deletedAt: null, latestTurn: null, branch: null, worktreePath: null, diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 8c09e89afa4..109f71ccd9a 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,4 +1,4 @@ -import type { Thread } from "./types"; +import type { ThreadShell } from "./types"; function normalizeWorktreePath(path: string | null): string | null { const trimmed = path?.trim(); @@ -9,8 +9,8 @@ function normalizeWorktreePath(path: string | null): string | null { } export function getOrphanedWorktreePathForThread( - threads: readonly Thread[], - threadId: Thread["id"], + threads: ReadonlyArray>, + threadId: ThreadShell["id"], ): string | null { const targetThread = threads.find((thread) => thread.id === threadId); if (!targetThread) { diff --git a/apps/web/test/authHttpHandlers.ts b/apps/web/test/authHttpHandlers.ts deleted file mode 100644 index a888f7fa5da..00000000000 --- a/apps/web/test/authHttpHandlers.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - AuthSessionId, - EnvironmentAuthenticatedAuth, - EnvironmentAuthenticatedPrincipal, - EnvironmentAuthHttpApi, - EnvironmentId, - EnvironmentMetadataHttpApi, - type AuthEnvironmentScope, - type ExecutionEnvironmentDescriptor, - type ServerAuthDescriptor, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { HttpRouter, HttpServer } from "effect/unstable/http"; -import * as HttpApi from "effect/unstable/httpapi/HttpApi"; -import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; -import { http } from "msw"; - -const BrowserEnvironmentHttpApi = HttpApi.make("browserEnvironment") - .add(EnvironmentMetadataHttpApi) - .add(EnvironmentAuthHttpApi); - -const TEST_SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-05-01T12:00:00.000Z"); -const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { - os: "darwin", - arch: "arm64", - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, -}; - -const unexpectedEndpoint = (endpoint: string) => - Effect.die(new Error(`Unexpected browser environment HTTP endpoint: ${endpoint}`)); - -export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { - const authenticatedAuthLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => - httpEffect.pipe( - Effect.provideService(EnvironmentAuthenticatedPrincipal, { - sessionId: AuthSessionId.make("browser-session"), - subject: "browser-client", - method: "browser-session-cookie", - scopes: new Set(), - expiresAt: TEST_SESSION_EXPIRES_AT, - }), - ), - ); - const metadataLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "metadata", (handlers) => - handlers.handle("descriptor", () => Effect.succeed(TEST_ENVIRONMENT_DESCRIPTOR)), - ); - const authLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "auth", (handlers) => - handlers - .handle("session", () => - Effect.succeed({ - authenticated: true, - auth: getAuthDescriptor(), - sessionMethod: "browser-session-cookie", - expiresAt: TEST_SESSION_EXPIRES_AT, - }), - ) - .handle("browserSession", () => - Effect.succeed({ - authenticated: true, - scopes: [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ], - sessionMethod: "browser-session-cookie", - expiresAt: TEST_SESSION_EXPIRES_AT, - }), - ) - .handle("token", () => unexpectedEndpoint("auth.token")) - .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) - .handle("pairingCredential", () => unexpectedEndpoint("auth.pairingCredential")) - .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks")) - .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink")) - .handle("clients", () => unexpectedEndpoint("auth.clients")) - .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient")) - .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")), - ).pipe(Layer.provide(authenticatedAuthLayer)); - const { handler } = HttpRouter.toWebHandler( - HttpApiBuilder.layer(BrowserEnvironmentHttpApi).pipe( - Layer.provide(metadataLayer), - Layer.provide(authLayer), - Layer.provide(authenticatedAuthLayer), - Layer.provide(HttpServer.layerServices), - ), - { disableLogger: true }, - ); - - return [ - http.all("*/.well-known/t3/environment", ({ request }) => handler(request)), - http.all("*/api/auth/*", ({ request }) => handler(request)), - ] as const; -} diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts deleted file mode 100644 index 94b8b53f3e1..00000000000 --- a/apps/web/test/wsRpcHarness.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { ORCHESTRATION_WS_METHODS, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as PubSub from "effect/PubSub"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; - -type RpcServerInstance = RpcServer.RpcServer; - -type BrowserWsClient = { - send: (data: string) => void; -}; - -export type NormalizedWsRpcRequestBody = { - _tag: string; - [key: string]: unknown; -}; - -type UnaryResolverResult = unknown | Promise; - -interface BrowserWsRpcHarnessOptions { - readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult; - readonly getInitialStreamValues?: ( - request: NormalizedWsRpcRequestBody, - ) => ReadonlyArray | undefined; -} - -const STREAM_METHODS = new Set([ - ORCHESTRATION_WS_METHODS.subscribeShell, - ORCHESTRATION_WS_METHODS.subscribeThread, - WS_METHODS.gitRunStackedAction, - WS_METHODS.terminalAttach, - WS_METHODS.subscribeVcsStatus, - WS_METHODS.subscribeTerminalEvents, - WS_METHODS.subscribeTerminalMetadata, - WS_METHODS.subscribeServerConfig, - WS_METHODS.subscribeServerLifecycle, - WS_METHODS.subscribeAuthAccess, -]); - -const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys()); - -function normalizeRequest(tag: string, payload: unknown): NormalizedWsRpcRequestBody { - if (payload && typeof payload === "object" && !Array.isArray(payload)) { - return { - _tag: tag, - ...(payload as Record), - }; - } - return { _tag: tag, payload }; -} - -function asEffect(result: UnaryResolverResult): Effect.Effect { - if (result instanceof Promise) { - return Effect.promise(() => result); - } - return Effect.succeed(result); -} - -export class BrowserWsRpcHarness { - readonly requests: Array = []; - - private readonly parser = RpcSerialization.json.makeUnsafe(); - private client: BrowserWsClient | null = null; - private scope: Scope.Closeable | null = null; - private serverReady: Promise | null = null; - private resolveUnary: NonNullable = () => ({}); - private getInitialStreamValues: NonNullable< - BrowserWsRpcHarnessOptions["getInitialStreamValues"] - > = () => []; - private streamPubSubs = new Map>(); - - async reset(options?: BrowserWsRpcHarnessOptions): Promise { - await this.disconnect(); - this.requests.length = 0; - this.resolveUnary = options?.resolveUnary ?? (() => ({})); - this.getInitialStreamValues = options?.getInitialStreamValues ?? (() => []); - this.initializeStreamPubSubs(); - } - - connect(client: BrowserWsClient): void { - if (this.scope) { - void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); - } - if (this.streamPubSubs.size === 0) { - this.initializeStreamPubSubs(); - } - this.client = client; - this.scope = Effect.runSync(Scope.make()); - this.serverReady = Effect.runPromise( - Scope.provide(this.scope)( - RpcServer.makeNoSerialization(WsRpcGroup, this.makeServerOptions()), - ).pipe(Effect.provide(this.makeLayer())), - ) as Promise; - } - - async disconnect(): Promise { - if (this.scope) { - await Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); - this.scope = null; - } - for (const pubsub of this.streamPubSubs.values()) { - Effect.runSync(PubSub.shutdown(pubsub)); - } - this.streamPubSubs.clear(); - this.serverReady = null; - this.client = null; - } - - private initializeStreamPubSubs(): void { - this.streamPubSubs = new Map( - Array.from(STREAM_METHODS, (method) => [method, Effect.runSync(PubSub.unbounded())]), - ); - } - - async onMessage(rawData: string): Promise { - const server = await this.serverReady; - if (!server) { - return; - } - const messages = this.parser.decode(rawData); - for (const message of messages) { - if (message && typeof message === "object" && "_tag" in message && message._tag === "Ping") { - const encoded = this.parser.encode(RpcMessage.constPong); - if (typeof encoded === "string") { - this.client?.send(encoded); - } - continue; - } - await Effect.runPromise(server.write(0, message as never)); - } - } - - emitStreamValue(method: string, value: unknown): void { - const pubsub = this.streamPubSubs.get(method); - if (!pubsub) { - throw new Error(`No stream registered for ${method}`); - } - Effect.runSync(PubSub.publish(pubsub, value)); - } - - private makeLayer() { - const handlers: Record unknown> = {}; - for (const method of ALL_RPC_METHODS) { - handlers[method] = STREAM_METHODS.has(method) - ? (payload) => this.handleStream(method, payload) - : (payload) => this.handleUnary(method, payload); - } - return WsRpcGroup.toLayer(handlers as never); - } - - private makeServerOptions() { - return { - onFromServer: (response: unknown) => - Effect.sync(() => { - if (!this.client) { - return; - } - const encoded = this.parser.encode(response); - if (typeof encoded === "string") { - this.client.send(encoded); - } - }), - }; - } - - private handleUnary(method: string, payload: unknown) { - const request = normalizeRequest(method, payload); - this.requests.push(request); - return asEffect(this.resolveUnary(request)); - } - - private handleStream(method: string, payload: unknown) { - const request = normalizeRequest(method, payload); - this.requests.push(request); - const pubsub = this.streamPubSubs.get(method); - if (!pubsub) { - throw new Error(`No stream registered for ${method}`); - } - return Stream.fromIterable(this.getInitialStreamValues(request) ?? []).pipe( - Stream.concat(Stream.fromPubSub(pubsub)), - ); - } -} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index be7b576f79f..fe07e1d6d49 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,7 +2,6 @@ import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; -import { playwright } from "vite-plus/test/browser-playwright"; import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config"; import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; @@ -59,30 +58,6 @@ const unitTestProject = { }, } satisfies TestProjectInlineConfiguration; -const browserTestProject = { - extends: true, - server: { - // Browser tests need concurrent runs to claim the next available port. - strictPort: false, - }, - test: { - name: "browser", - include: ["src/components/**/*.browser.tsx"], - hookTimeout: 30_000, - testTimeout: 30_000, - browser: { - enabled: true, - provider: playwright() as never, - instances: [{ browser: "chromium" }], - headless: true, - api: { - strictPort: false, - }, - }, - fileParallelism: false, - }, -} satisfies TestProjectInlineConfiguration; - function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { if (!wsUrl) { return undefined; @@ -124,6 +99,8 @@ export default defineConfig(() => { ], optimizeDeps: { include: [ + "@clerk/clerk-js", + "@clerk/react/internal", "@pierre/diffs", "@pierre/diffs/editor", "@pierre/diffs/react", @@ -150,6 +127,7 @@ export default defineConfig(() => { }, resolve: { tsconfigPaths: true, + dedupe: ["react", "react-dom"], }, server: { host, @@ -187,7 +165,7 @@ export default defineConfig(() => { sourcemap: buildSourcemap, }, test: { - projects: [defineProject(unitTestProject), defineProject(browserTestProject)], + projects: [defineProject(unitTestProject)], }, }; }); diff --git a/docs/architecture/connection-runtime.md b/docs/architecture/connection-runtime.md new file mode 100644 index 00000000000..06f7e6338ca --- /dev/null +++ b/docs/architecture/connection-runtime.md @@ -0,0 +1,137 @@ +# Connection Runtime + +The connection runtime is shared by web and mobile. It owns connectivity, +authentication, retries, transport lifetime, cached environment data, and +environment-scoped operations. + +Web and mobile mount this runtime once at the application root. There is no +legacy connection owner or supported mixed mode. + +## Ownership + +Each registered environment has one scoped Effect `Context` containing focused +services: + +- `EnvironmentSupervisor` owns desired state, retry scheduling, and the active + session scope. +- `ConnectionBroker` prepares credentials and endpoints for primary, bearer, + relay, and SSH targets. +- `RpcSessionFactory` performs one transport attempt. It does not retry. +- `EnvironmentRpc` exposes the active session without leaking the transport. +- `EnvironmentProjectCommands` and `EnvironmentThreadCommands` construct + orchestration commands, IDs, and timestamps. +- `EnvironmentShell` and `EnvironmentThreads` own live subscriptions and cached + snapshots. + +`EnvironmentServicesFactory` assembles that context, and `EnvironmentRegistry` +owns its scope. There is no aggregate environment runtime facade. React +components do not create connections, transports, retry loops, or RPC clients. + +## Connection State + +The supervisor is the only retry owner. + +1. A persisted or platform registration marks an environment as desired. +2. If the device is offline, the supervisor releases the active session and + waits without consuming retry attempts. +3. When online, the supervisor asks the broker for one prepared connection and + asks the session factory for one RPC session. +4. Transient failures retry forever with exponential backoff capped at 16 + seconds. +5. Connectivity changes, application activation, credential changes, and + explicit user retry interrupt the current wait and trigger a fresh attempt. +6. Authentication or configuration failures remain blocked until an external + wakeup changes the relevant input. +7. An involuntary session close keeps the registration and cache, then retries. +8. Explicit removal closes the session and deletes the registration, + credentials, shell cache, and thread cache. + +The UI derives `available`, `offline`, `connecting`, `reconnecting`, +`connected`, and `error` from supervisor state plus explicit data-sync state. +It does not infer connection health from cached data or the existence of a +transport object. An environment becomes `connected` after the socket opens and +the initial config RPC succeeds, proving that the server is responsive. Shell +and thread synchronization are independent data states. A healthy RPC +transport with a failed shell subscription is shown as connected with a +synchronization error, not as a reconnect that is not actually scheduled. + +## Data Boundary + +Finite requests, durable subscriptions, and commands are separate APIs: + +- Query atoms revalidate when the RPC generation changes. +- Subscription atoms switch to replacement sessions. +- Expected subscription failures update domain sync state and wait for a + replacement session; they do not take down a healthy transport. +- Mutations resolve the current environment runtime at execution time. +- Shell and thread snapshots are available while offline. +- A connected transport may have `empty`, `cached`, `synchronizing`, `live`, or + failed shell and thread data independently. +- Cached shell and thread projections are never allowed to overwrite newer live + data during a fast reconnect. +- Domain atom factories route effects through the environment registry and + resolve the current scoped service at execution time. +- Web and mobile own their Atom runtimes, React hooks, and feature composition. + +The Promise bridge exists only at the React/Atom boundary. Runtime and business +logic remain Effect-native. + +## Platform Layers + +Web and mobile provide: + +- network status and network-change streams; +- application lifecycle wakeups; +- cloud session credentials; +- device identity; +- platform registrations; +- persistent catalog, credential, shell, and thread stores; +- HTTP, crypto, and telemetry layers. + +Platform layers adapt operating-system capabilities. They do not implement +connection policy. + +## Source Boundaries + +The public package subpaths mirror the runtime layers: + +- `connection/core` contains state, catalog, retry policy, and connectivity. +- `connection/transport` contains brokerage, authorization, attempts, and RPC + sessions. +- `connection/platform` declares capabilities and persistence contracts. +- `connection/services` contains environment-scoped data services. +- `connection/application` assembles registries, discovery, and startup. +- `connection/atoms` adapts shared services to application-owned Atom runtimes. +- `connection/presentation` contains pure UI projections. + +Other reusable state lives in domain subpaths such as `shell`, `threads`, +`terminal`, and `vcs`. Applications must import explicit package subpaths; the +package intentionally has no root export. + +## Application Boundary + +The application root mounts the shared connection application layer, creates +its own Atom runtime, and selects the domain atom factories required by that +platform. Web and mobile may expose different hooks and features without +changing connection ownership. + +Application code must not construct `WsTransport`, RPC clients, retry loops, or +raw orchestration commands. Persistence paths belong to the platform +registration and cache stores, with explicit migration or invalidation policy. + +## Verification + +Core state-machine tests use `@effect/vitest` and deterministic service layers. +Required coverage includes: + +- offline startup and online wakeup; +- forever retry with the 16-second cap; +- explicit retry interrupting backoff; +- authentication wakeups; +- involuntary close and reconnect; +- explicit removal clearing all owned state; +- relay token reuse and refresh; +- progressive relay discovery; +- shell and thread cache hydration; +- durable subscriptions switching sessions; +- command metadata and idempotent queued-command metadata. diff --git a/docs/cloud/t3-connect-clerk.md b/docs/cloud/t3-connect-clerk.md index d768c387413..3fd1943f7dc 100644 --- a/docs/cloud/t3-connect-clerk.md +++ b/docs/cloud/t3-connect-clerk.md @@ -120,20 +120,86 @@ selects the concrete relay deployment, but changing that URL does not require a ## Desktop OAuth Redirect Allowlist The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme. -In **Clerk Dashboard > Native applications**, enable native application support and add these -entries under the mobile SSO redirect allowlist: +In **Clerk Dashboard > Native applications**, enable the Native API and add these entries under the +mobile SSO redirect allowlist: ```text -t3code-dev://auth/callback -t3code://auth/callback +t3code-dev://app/ +t3code://app/ ``` -The first entry is for local desktop development. The second is for packaged desktop builds. -The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial -sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps -Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback, -and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth -redirect: an external browser cannot use it to reopen the packaged app. +Local desktop development uses `t3code-dev://app`, while packaged builds use `t3code://app`. Add the +matching origin to each Clerk instance's Backend API `allowed_origins` array as well. The development +Clerk instance should only need `t3code-dev://app`; the production Clerk instance should only need +`t3code://app`. `@clerk/electron` owns the native request adapter, encrypted Clerk token persistence, +external-browser OAuth transport, and callback delivery for initial sign-in and linked-account flows. + +There is currently no Dashboard UI for `allowed_origins`. Preserve any existing entries and update +the instance through the Backend API: + +```sh +curl -X PATCH https://api.clerk.com/v1/instance \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CLERK_SECRET_KEY" \ + -d '{"allowed_origins":["t3code://app"]}' +``` + +Never put `CLERK_SECRET_KEY` in the desktop app, a client-facing environment file, or a build +artifact. + +## Desktop Passkeys + +The production macOS bundle ID is `com.t3tools.t3code`. To enable native passkeys: + +1. Create an explicit macOS App ID for `com.t3tools.t3code` in the Apple Developer portal and enable + **Associated Domains**. +2. Create a compatible macOS provisioning profile for that App ID and the certificate used to sign + the distributed app. +3. In Clerk's Native API settings, add an iOS app with the same Apple Team ID and bundle ID. This is + also the configuration point for Electron/macOS passkeys. +4. Confirm Clerk serves `https:///.well-known/apple-app-site-association` and that + `webcredentials.apps` contains `.com.t3tools.t3code`. +5. Set the local or CI signing configuration described below. + +For a local signed build, add these values to `.env.local` or export them before invoking the +desktop artifact command: + +```dotenv +T3CODE_APPLE_TEAM_ID=ABC1234567 +T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile +# Optional: comma-separated override when Clerk's RP ID differs from the Frontend API hostname. +T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com +``` + +When `T3CODE_CLERK_PASSKEY_RP_DOMAINS` is absent, the build derives the RP domain from +`T3CODE_CLERK_PUBLISHABLE_KEY`. Signed macOS builds fail early if the Team ID, provisioning profile, +or RP-domain configuration is missing. The generated main-app entitlements include every configured +`webcredentials:` entry; helper apps keep Electron's minimal default entitlements. + +The normal `dev:desktop` launcher is unsigned and cannot complete macOS passkey ceremonies. For +renderer HMR, build and install a signed app first, run the renderer dev server, then launch the +installed app executable with `VITE_DEV_SERVER_URL` and `T3CODE_PORT` set. Rebuild the signed app +after native dependency, main-process, preload, entitlement, provisioning, or signing changes; +renderer-only changes can reuse the installed app. + +For the default development ports, run `pnpm dev:web` in one terminal and launch the installed +binary from another: + +```sh +VITE_DEV_SERVER_URL=http://127.0.0.1:5733 \ +T3CODE_PORT=13773 \ + "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)" +``` + +After changing Associated Domains, bump the build version before rebuilding; macOS may otherwise +reuse stale Shared Web Credentials metadata for the same app/version pair. + +Verify the installed bundle before testing: + +```sh +codesign --verify --deep --strict "/Applications/T3 Code (Alpha).app" +codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app" +``` The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth flow uses a custom redirect URI, add that exact URI to the same allowlist. diff --git a/docs/operations/ci.md b/docs/operations/ci.md index 244446ba959..d030b446b6a 100644 --- a/docs/operations/ci.md +++ b/docs/operations/ci.md @@ -2,5 +2,5 @@ - `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. - `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. -- The release workflow auto-enables signing only when secrets are present: Apple credentials for macOS and Azure Trusted Signing credentials for Windows. Without secrets, it still releases unsigned artifacts. +- The release workflow auto-enables signing only when platform credentials are present. macOS passkey builds additionally require `APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. Without the core signing credentials, it still releases unsigned artifacts. - See [Release Checklist](./release.md) for the full release/signing setup checklist. diff --git a/docs/operations/effect-fn-checklist.md b/docs/operations/effect-fn-checklist.md index 1addfdf4dd4..279b5646d32 100644 --- a/docs/operations/effect-fn-checklist.md +++ b/docs/operations/effect-fn-checklist.md @@ -130,12 +130,12 @@ Effect.fn("name")( - [x] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555) - [x] Remaining nested callback wrappers in this file -### `apps/server/src/checkpointing/Layers/CheckpointStore.ts` (`10`) +### `apps/server/src/checkpointing/CheckpointStore.ts` (`10`) -- [ ] [captureCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L89) -- [ ] [restoreCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L183) -- [ ] [diffCheckpoints](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L220) -- [ ] [deleteCheckpointRefs](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L252) +- [ ] [captureCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L123) +- [ ] [restoreCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L137) +- [ ] [diffCheckpoints](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L144) +- [ ] [deleteCheckpointRefs](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L151) - [ ] Nested callback wrappers in this file ### `apps/server/src/provider/Layers/EventNdjsonLogger.ts` (`9`) @@ -190,7 +190,7 @@ Effect.fn("name")( - [ ] [apps/server/src/persistence/Migrations.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/Migrations.ts) (`2`) - [ ] [apps/server/src/open.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/open.ts) (`2`) - [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`) -- [ ] [apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts) (`2`) +- [ ] [apps/server/src/checkpointing/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointDiffQuery.ts) (`2`) - [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`) ``` diff --git a/docs/operations/release.md b/docs/operations/release.md index 8f149446b66..76f787dc023 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -219,26 +219,44 @@ Required secrets used by the workflow: - `APPLE_API_KEY` - `APPLE_API_KEY_ID` - `APPLE_API_ISSUER` +- `MACOS_PROVISIONING_PROFILE` (base64-encoded provisioning profile with Associated Domains) + +Required repository variables: + +- `APPLE_TEAM_ID` + +Optional repository variables: + +- `CLERK_PASSKEY_RP_DOMAINS`: comma-separated RP-domain override. By default, the build derives the + domain from the production Clerk publishable key. Checklist: 1. Apple Developer account access: - Team has rights to create Developer ID certificates. -2. Create `Developer ID Application` certificate. -3. Export certificate + private key as `.p12` from Keychain. -4. Base64-encode the `.p12` and store as `CSC_LINK`. -5. Store the `.p12` export password as `CSC_KEY_PASSWORD`. -6. In App Store Connect, create an API key (Team key). -7. Add API key values: +2. Create an explicit App ID for `com.t3tools.t3code` and enable Associated Domains. +3. Create a `Developer ID Application` certificate and a compatible provisioning profile for that + App ID with Associated Domains enabled. +4. Export the certificate + private key as `.p12` from Keychain. +5. Base64-encode the `.p12` and store as `CSC_LINK`. +6. Base64-encode the provisioning profile and store it as `MACOS_PROVISIONING_PROFILE`. +7. Store the `.p12` export password as `CSC_KEY_PASSWORD`, and set `APPLE_TEAM_ID` to the + 10-character Apple Developer Team ID. +8. In App Store Connect, create an API key (Team key). +9. Add API key values: - `APPLE_API_KEY`: contents of the downloaded `.p8` - `APPLE_API_KEY_ID`: Key ID - `APPLE_API_ISSUER`: Issuer ID -8. Re-run a tag release and confirm macOS artifacts are signed/notarized. +10. Complete the Clerk Native API and AASA setup in [T3 Connect Clerk Setup](../cloud/t3-connect-clerk.md#desktop-passkeys). +11. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected + `com.apple.developer.associated-domains` entitlement. Notes: - `APPLE_API_KEY` is stored as raw key text in secrets. - The workflow writes it to a temporary `AuthKey_.p8` file at runtime. +- The workflow decodes `MACOS_PROVISIONING_PROFILE`, validates it with `security cms`, and passes it + to the desktop packager. ## 3) Azure Trusted Signing setup (Windows) @@ -281,7 +299,9 @@ Checklist: ## 5) Troubleshooting - macOS build unsigned when expected signed: - - Check all Apple secrets are populated and non-empty. + - Check all Apple secrets plus `APPLE_TEAM_ID` are populated and non-empty. + - Confirm the provisioning profile belongs to `APPLE_TEAM_ID.com.t3tools.t3code` and includes + Associated Domains. - Windows build unsigned when expected signed: - Check all Azure ATS and auth secrets are populated and non-empty. - Build fails with signing error: diff --git a/docs/reference/encyclopedia.md b/docs/reference/encyclopedia.md index 76df004e044..82a58fd959c 100644 --- a/docs/reference/encyclopedia.md +++ b/docs/reference/encyclopedia.md @@ -172,8 +172,8 @@ The file patch and changed-file summary for one turn. It is usually computed in [16]: ./provider-architecture.md [17]: ../apps/server/src/provider/Layers/CodexAdapter.ts [18]: ./runtime-modes.md -[19]: ../apps/server/src/checkpointing/Services/CheckpointStore.ts -[20]: ../apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +[19]: ../apps/server/src/checkpointing/CheckpointStore.ts +[20]: ../apps/server/src/checkpointing/CheckpointDiffQuery.ts [21]: ../apps/server/src/persistence/Services/ProjectionCheckpoints.ts [22]: ../apps/server/src/checkpointing/Utils.ts [23]: ../apps/server/src/checkpointing/Diffs.ts diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index b3fcd4b30e9..d4d2b96869e 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -20,11 +20,14 @@ - Default build is unsigned/not notarized for local sharing. - The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. -- Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL). +- Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL). - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. - To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` - To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. +- Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and + `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from + `T3CODE_CLERK_PUBLISHABLE_KEY` unless `T3CODE_CLERK_PASSKEY_RP_DOMAINS` overrides it. - Windows `--signed` uses Azure Trusted Signing and expects: `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`. diff --git a/infra/relay/README.md b/infra/relay/README.md index 697fa30cac0..114d5e9b07f 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -45,7 +45,7 @@ credential, or authorization behavior. Shared request and response schemas live in [`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay calls live in -[`packages/client-runtime/src/managedRelay.ts`](../../packages/client-runtime/src/managedRelay.ts). +[`packages/client-runtime/src/relay/managedRelay.ts`](../../packages/client-runtime/src/relay/managedRelay.ts). ## Working Locally diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index b9e35fd132d..c4ebb2d80e4 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -7,7 +7,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Planetscale from "alchemy/Planetscale"; -import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; +import * as RelayDb from "./src/db.ts"; import { RelayObservability } from "./src/observability.ts"; import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; import Api from "./src/worker.ts"; @@ -24,8 +24,8 @@ export default Alchemy.Stack( state: Cloudflare.state(), }, Effect.gen(function* () { - const db = yield* PlanetscaleDatabase; - const hyperdrive = yield* RelayHyperdrive; + const db = yield* RelayDb.PlanetscaleDatabase; + const hyperdrive = yield* RelayDb.RelayHyperdrive; const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie); const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); const observability = yield* RelayObservability; diff --git a/infra/relay/package.json b/infra/relay/package.json index 213c1fe5cc8..eebd9f4721a 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -9,7 +9,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@clerk/backend": "3.6.1", + "@clerk/backend": "catalog:", "@effect/sql-pg": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index 3e512e2818c..06d0bcd61fb 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -6,13 +6,57 @@ import * as Path from "effect/Path"; import { hasDeployChanges, + missingRelayPublicConfigFields, publicConfigFromOutput, reconcileRootEnvPublicConfig, reconcileRootEnvRelayUrl, + RelayDeployError, + RelayDeployPublicConfigUnavailableError, serializeGithubOutput, serializeRelayClientTracingEnvironment, } from "./deploy.ts"; +describe("RelayDeployError", () => { + it("reports the incomplete state source, stage, and missing fields", () => { + const missingFields = missingRelayPublicConfigFields({ + url: "https://relay.example.test", + mobileTracingUrl: "https://api.axiom.co/v1/traces", + }); + const error = new RelayDeployError({ + source: "alchemy_state", + stage: "production", + missingFields, + }); + + expect(error).toMatchObject({ + source: "alchemy_state", + stage: "production", + missingFields: [ + "mobileTracingDataset", + "mobileTracingToken", + "clientTracingUrl", + "clientTracingDataset", + "clientTracingToken", + ], + }); + expect(error.message).toBe( + "Relay deploy output from 'alchemy_state' for stage 'production' is missing required public config fields: mobileTracingDataset, mobileTracingToken, clientTracingUrl, clientTracingDataset, clientTracingToken", + ); + }); + + it("distinguishes deploy results that do not produce public config", () => { + const error = new RelayDeployPublicConfigUnavailableError({ + result: "dry-run", + stage: "production", + outputPath: "/tmp/relay-client.env", + }); + + expect(error.message).toBe( + "Relay deploy result 'dry-run' for stage 'production' did not produce public config required by GitHub environment output '/tmp/relay-client.env'.", + ); + }); +}); + describe("hasDeployChanges", () => { it("detects resource, binding, and deletion changes", () => { expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false); diff --git a/infra/relay/scripts/deploy.ts b/infra/relay/scripts/deploy.ts index 2ef82ec4ffa..698df3d5476 100644 --- a/infra/relay/scripts/deploy.ts +++ b/infra/relay/scripts/deploy.ts @@ -19,21 +19,65 @@ import { PlatformServices } from "alchemy/Util/PlatformServices"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Console from "effect/Console"; -import * as Data from "effect/Data"; 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"; import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; import { Command, Flag, Prompt } from "effect/unstable/cli"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import RelayStack from "../alchemy.run.ts"; -export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{ - readonly message: string; -}> {} +const relayDeployOutputFields = [ + "url", + "mobileTracingUrl", + "mobileTracingDataset", + "mobileTracingToken", + "clientTracingUrl", + "clientTracingDataset", + "clientTracingToken", +] as const; + +export const RelayDeployOutputField = Schema.Literals(relayDeployOutputFields); +export type RelayDeployOutputField = typeof RelayDeployOutputField.Type; + +export const RelayDeployResult = Schema.Literals([ + "applied", + "noop", + "dry-run", + "cancelled", + "state", +]); +export type RelayDeployResult = typeof RelayDeployResult.Type; + +export class RelayDeployError extends Schema.TaggedErrorClass()( + "RelayDeployError", + { + source: Schema.Literals(["alchemy_state", "alchemy_apply"]), + stage: Schema.String, + missingFields: Schema.Array(RelayDeployOutputField), + }, +) { + override get message(): string { + return `Relay deploy output from '${this.source}' for stage '${this.stage}' is missing required public config fields: ${this.missingFields.join(", ")}`; + } +} + +export class RelayDeployPublicConfigUnavailableError extends Schema.TaggedErrorClass()( + "RelayDeployPublicConfigUnavailableError", + { + result: RelayDeployResult, + stage: Schema.String, + outputPath: Schema.String, + }, +) { + override get message(): string { + return `Relay deploy result '${this.result}' for stage '${this.stage}' did not produce public config required by GitHub environment output '${this.outputPath}'.`; + } +} export interface RelayDeployOptions { readonly dryRun: boolean; @@ -112,8 +156,6 @@ export function hasDeployChanges(plan: Plan.Plan): boolean { ); } -export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled" | "state"; - export interface RelayDeployOutcome { readonly result: RelayDeployResult; readonly changed: boolean; @@ -199,10 +241,13 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* ( outcome: RelayDeployOutcome, outputPath: string, + stage: string, ) { if (Option.isNone(outcome.publicConfig)) { - return yield* new RelayDeployError({ - message: "Relay public client config is unavailable for the GitHub environment file", + return yield* new RelayDeployPublicConfigUnavailableError({ + result: outcome.result, + stage, + outputPath, }); } const fs = yield* FileSystem.FileSystem; @@ -224,44 +269,71 @@ const deployBaseServices = Layer.mergeAll( ); const deployServices = deployBaseServices; -export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null { +function relayPublicConfigValues( + output: unknown, +): Readonly> { if (typeof output !== "object" || output === null) { - return null; + return { + url: undefined, + mobileTracingUrl: undefined, + mobileTracingDataset: undefined, + mobileTracingToken: undefined, + clientTracingUrl: undefined, + clientTracingDataset: undefined, + clientTracingToken: undefined, + }; } const value = output as Record; - const text = (name: string) => (typeof value[name] === "string" ? value[name] : undefined); + const text = (name: string) => { + const candidate = value[name]; + return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined; + }; const secret = (name: string): string | undefined => { const candidate = value[name]; if (!Redacted.isRedacted(candidate)) { return text(name); } const redacted = Redacted.value(candidate); - return typeof redacted === "string" ? redacted : undefined; + return typeof redacted === "string" && redacted.length > 0 ? redacted : undefined; + }; + return { + url: text("url"), + mobileTracingUrl: text("mobileTracingUrl"), + mobileTracingDataset: text("mobileTracingDataset"), + mobileTracingToken: secret("mobileTracingToken"), + clientTracingUrl: text("clientTracingUrl"), + clientTracingDataset: text("clientTracingDataset"), + clientTracingToken: secret("clientTracingToken"), + }; +} + +export function missingRelayPublicConfigFields( + output: unknown, +): ReadonlyArray { + const values = relayPublicConfigValues(output); + return relayDeployOutputFields.filter((field) => values[field] === undefined); +} + +function hasCompleteRelayPublicConfigValues( + values: Readonly>, +): values is Readonly> { + return relayDeployOutputFields.every((field) => values[field] !== undefined); +} + +export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null { + const values = relayPublicConfigValues(output); + if (!hasCompleteRelayPublicConfigValues(values)) { + return null; + } + return { + relayUrl: values.url, + mobileTracingUrl: values.mobileTracingUrl, + mobileTracingDataset: values.mobileTracingDataset, + mobileTracingToken: values.mobileTracingToken, + clientTracingUrl: values.clientTracingUrl, + clientTracingDataset: values.clientTracingDataset, + clientTracingToken: values.clientTracingToken, }; - const relayUrl = text("url"); - const mobileTracingUrl = text("mobileTracingUrl"); - const mobileTracingDataset = text("mobileTracingDataset"); - const mobileTracingToken = secret("mobileTracingToken"); - const clientTracingUrl = text("clientTracingUrl"); - const clientTracingDataset = text("clientTracingDataset"); - const clientTracingToken = secret("clientTracingToken"); - return relayUrl && - mobileTracingUrl && - mobileTracingDataset && - mobileTracingToken && - clientTracingUrl && - clientTracingDataset && - clientTracingToken - ? { - relayUrl, - mobileTracingUrl, - mobileTracingDataset, - mobileTracingToken, - clientTracingUrl, - clientTracingDataset, - clientTracingToken, - } - : null; } const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) { @@ -271,7 +343,9 @@ const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (sta const publicConfig = publicConfigFromOutput(output); if (publicConfig === null) { return yield* new RelayDeployError({ - message: `Alchemy relay state for stage ${stage} did not include complete public client config`, + source: "alchemy_state", + stage, + missingFields: missingRelayPublicConfigFields(output), }); } return { @@ -285,7 +359,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( function* ( options: RelayDeployOptions, _configProvider: ConfigProvider.ConfigProvider, - _stage: string, + stage: string, ) { const stack = yield* RelayStack; const cli = yield* Cli; @@ -318,31 +392,18 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( } } const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); - if ( - output.url === undefined || - output.mobileTracingUrl === undefined || - output.mobileTracingDataset === undefined || - output.mobileTracingToken === undefined || - output.clientTracingUrl === undefined || - output.clientTracingDataset === undefined || - output.clientTracingToken === undefined - ) { + const publicConfig = publicConfigFromOutput(output); + if (publicConfig === null) { return yield* new RelayDeployError({ - message: "Alchemy relay deploy output did not include complete public client config", + source: "alchemy_apply", + stage, + missingFields: missingRelayPublicConfigFields(output), }); } return { result: changed ? "applied" : "noop", changed, - publicConfig: Option.some({ - relayUrl: output.url, - mobileTracingUrl: output.mobileTracingUrl, - mobileTracingDataset: output.mobileTracingDataset, - mobileTracingToken: Redacted.value(output.mobileTracingToken), - clientTracingUrl: output.clientTracingUrl, - clientTracingDataset: output.clientTracingDataset, - clientTracingToken: Redacted.value(output.clientTracingToken), - }), + publicConfig: Option.some(publicConfig), } satisfies RelayDeployOutcome; }, (effect, options, configProvider, stage) => @@ -379,7 +440,7 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO yield* writeGithubOutput(outcome); } if (Option.isSome(options.githubEnvFile)) { - yield* writeGithubEnvFile(outcome, options.githubEnvFile.value); + yield* writeGithubEnvFile(outcome, options.githubEnvFile.value, stage); } }); diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index 23f3ba061b1..e7c7d42f2ae 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; @@ -13,20 +14,24 @@ export interface ApnsCredentials { readonly environment: ApnsEnvironment; } -export interface RelayConfigurationShape { - readonly relayIssuer: string; - readonly apns: ApnsCredentials; - readonly clerkSecretKey: Redacted.Redacted; - readonly clerkPublishableKey: string; - readonly clerkJwtAudience: string; - readonly apnsDeliveryJobSigningSecret: Redacted.Redacted; - readonly cloudMintPrivateKey: Redacted.Redacted; - readonly cloudMintPublicKey: string; - readonly managedEndpointBaseDomain: string | undefined; - readonly managedEndpointNamespace: string | undefined; -} - export class RelayConfiguration extends Context.Service< RelayConfiguration, - RelayConfigurationShape + { + readonly relayIssuer: string; + readonly apns: ApnsCredentials; + readonly clerkSecretKey: Redacted.Redacted; + readonly clerkPublishableKey: string; + readonly clerkJwtAudience: string; + readonly apnsDeliveryJobSigningSecret: Redacted.Redacted; + readonly cloudMintPrivateKey: Redacted.Redacted; + readonly cloudMintPublicKey: string; + readonly managedEndpointBaseDomain: string | undefined; + readonly managedEndpointNamespace: string | undefined; + } >()("t3code-relay/Config/RelayConfiguration") {} + +export const make = (configuration: RelayConfiguration["Service"]) => + RelayConfiguration.of(configuration); + +export const layer = (configuration: RelayConfiguration["Service"]) => + Layer.succeed(RelayConfiguration, make(configuration)); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 5f27c2f1821..9671f4984b2 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -41,8 +41,8 @@ function target(deviceId: string): LiveActivities.TargetRow { } function makeLiveActivities( - overrides: Partial = {}, -): LiveActivities.LiveActivitiesShape { + overrides: Partial = {}, +): LiveActivities.LiveActivities["Service"] { return { register: () => Effect.void, listTargets: () => Effect.succeed([]), @@ -55,8 +55,8 @@ function makeLiveActivities( } function makeAgentActivityRows( - overrides: Partial = {}, -): AgentActivityRows.AgentActivityRowsShape { + overrides: Partial = {}, +): AgentActivityRows.AgentActivityRows["Service"] { return { upsert: () => Effect.void, remove: () => Effect.void, @@ -66,8 +66,8 @@ function makeAgentActivityRows( } function makeEnvironmentLinks( - overrides: Partial = {}, -): EnvironmentLinks.EnvironmentLinksShape { + overrides: Partial = {}, +): EnvironmentLinks.EnvironmentLinks["Service"] { return { upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), @@ -88,8 +88,8 @@ function makeEnvironmentLinks( } function makeApnsDeliveries( - overrides: Partial = {}, -): ApnsDeliveries.ApnsDeliveriesShape { + overrides: Partial = {}, +): ApnsDeliveries.ApnsDeliveries["Service"] { return { sendForTarget: () => Effect.succeed(null), sendPushNotificationForTarget: () => Effect.succeed(null), @@ -133,7 +133,8 @@ describe("AgentActivityPublisher", () => { remote_start_queued_at: null, remote_started_at: "1970-01-01T00:00:01.000Z", }; - const sent: Array[0]> = []; + const sent: Array[0]> = + []; const deliveryResult: RelayDeliveryResult = { deviceId: "device-1", kind: "live_activity_update", @@ -211,7 +212,8 @@ describe("AgentActivityPublisher", () => { readonly environmentId: string; readonly environmentPublicKey: string; }> = []; - const upserts: Array[0]> = []; + const upserts: Array[0]> = + []; return Effect.gen(function* () { const result = yield* Effect.gen(function* () { @@ -302,9 +304,10 @@ describe("AgentActivityPublisher", () => { updatedAt: "1970-01-01T00:00:10.000Z", }; const sentAggregates: Array< - Parameters[0] + Parameters[0] > = []; - const removals: Array[0]> = []; + const removals: Array[0]> = + []; return Effect.gen(function* () { const result = yield* Effect.gen(function* () { @@ -405,10 +408,10 @@ describe("AgentActivityPublisher", () => { headline: "Needs input", }; const liveAggregates: Array< - Parameters[0] + Parameters[0] > = []; const pushAggregates: Array< - Parameters[0] + Parameters[0] > = []; return Effect.gen(function* () { @@ -517,10 +520,10 @@ describe("AgentActivityPublisher", () => { headline: "Needs approval", }; const liveAggregates: Array< - Parameters[0] + Parameters[0] > = []; const pushAggregates: Array< - Parameters[0] + Parameters[0] > = []; return Effect.gen(function* () { diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 0f5ddc32137..abe05f07da2 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -23,25 +23,23 @@ export type AgentActivityPublishError = | LiveActivities.LiveActivityTargetListPersistenceError | ApnsDeliveries.ApnsDeliveryError; -export interface AgentActivityPublisherShape { - readonly publish: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - readonly threadId: string; - readonly state: RelayAgentActivityState | null; - }) => Effect.Effect; - readonly replayForLiveActivityRegistration: (input: { - readonly userId: string; - readonly deviceId: string; - }) => Effect.Effect; -} - export class AgentActivityPublisher extends Context.Service< AgentActivityPublisher, - AgentActivityPublisherShape + { + readonly publish: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + readonly state: RelayAgentActivityState | null; + }) => Effect.Effect; + readonly replayForLiveActivityRegistration: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; + } >()("t3code-relay/agentActivity/AgentActivityPublisher") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const rows = yield* AgentActivityRows.AgentActivityRows; const links = yield* EnvironmentLinks.EnvironmentLinks; const liveActivities = yield* LiveActivities.LiveActivities; diff --git a/infra/relay/src/agentActivity/AgentActivityRows.test.ts b/infra/relay/src/agentActivity/AgentActivityRows.test.ts new file mode 100644 index 00000000000..be976d16bbb --- /dev/null +++ b/infra/relay/src/agentActivity/AgentActivityRows.test.ts @@ -0,0 +1,84 @@ +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as RelayDb from "../db.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; + +const state: RelayAgentActivityState = { + environmentId: "env-1" as RelayAgentActivityState["environmentId"], + threadId: "thread-1" as RelayAgentActivityState["threadId"], + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + headline: "Running", + updatedAt: "2026-06-20T00:00:00.000Z", + deepLink: "/threads/env-1/thread-1", +}; + +describe("AgentActivityRows", () => { + it.effect("preserves activity context on persistence failures", () => { + const cause = new Error("database unavailable"); + const failingDb = { + insert: () => ({ + values: () => ({ + onConflictDoUpdate: () => Effect.fail(cause), + }), + }), + delete: () => ({ + where: () => Effect.fail(cause), + }), + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ + orderBy: () => Effect.fail(cause), + }), + }), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const rows = yield* AgentActivityRows.AgentActivityRows; + + const upsertError = yield* rows + .upsert({ environmentPublicKey: "public-key", state }) + .pipe(Effect.flip); + expect(upsertError).toMatchObject({ + environmentId: "env-1", + threadId: "thread-1", + cause, + }); + expect(upsertError.message).toBe( + "Failed to persist agent activity state for environment env-1, thread thread-1.", + ); + + const deleteError = yield* rows + .remove({ + environmentId: "env-1", + environmentPublicKey: "public-key", + threadId: "thread-1", + }) + .pipe(Effect.flip); + expect(deleteError).toMatchObject({ + environmentId: "env-1", + threadId: "thread-1", + cause, + }); + expect(deleteError.message).toBe( + "Failed to delete agent activity state for environment env-1, thread thread-1.", + ); + + const listError = yield* rows.listForUser({ userId: "user-2" }).pipe(Effect.flip); + expect(listError).toMatchObject({ userId: "user-2", cause }); + expect(listError.message).toBe("Failed to list agent activity state for user user-2."); + }).pipe( + Effect.provide( + AgentActivityRows.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, failingDb))), + ), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 6f940c5523f..7e1a8c50f1b 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -3,60 +3,73 @@ import { RelayAgentActivityState as RelayAgentActivityStateSchema } from "@t3too import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { cast } from "effect/Function"; +import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { and, desc, eq, isNull } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; export class AgentActivityRowUpsertPersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowUpsertPersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + threadId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist agent activity state"; + return `Failed to persist agent activity state for environment ${this.environmentId}, thread ${this.threadId}.`; } } export class AgentActivityRowDeletePersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowDeletePersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + threadId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to delete agent activity state"; + return `Failed to delete agent activity state for environment ${this.environmentId}, thread ${this.threadId}.`; } } export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list agent activity state"; + return `Failed to list agent activity state for user ${this.userId}.`; } } -export interface AgentActivityRowsShape { - readonly upsert: (input: { - readonly environmentPublicKey: string; - readonly state: RelayAgentActivityState; - }) => Effect.Effect; - readonly remove: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - readonly threadId: string; - }) => Effect.Effect; - readonly listForUser: (input: { - readonly userId: string; - }) => Effect.Effect, AgentActivityRowListPersistenceError>; -} - -export class AgentActivityRows extends Context.Service()( - "t3code-relay/agentActivity/AgentActivityRows", -) {} +export class AgentActivityRows extends Context.Service< + AgentActivityRows, + { + readonly upsert: (input: { + readonly environmentPublicKey: string; + readonly state: RelayAgentActivityState; + }) => Effect.Effect; + readonly remove: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + }) => Effect.Effect; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect< + ReadonlyArray, + AgentActivityRowListPersistenceError + >; + } +>()("t3code-relay/agentActivity/AgentActivityRows") {} const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); @@ -69,45 +82,60 @@ const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityStateSchema), ); -const make = Effect.gen(function* () { - const db = yield* RelayDb; +export const make = Effect.gen(function* () { + const db = yield* RelayDb.RelayDb; return AgentActivityRows.of({ - upsert: Effect.fn("relay.agent_activity_rows.upsert")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.state.environmentId, - "relay.thread_id": input.state.threadId, - }); - const now = yield* DateTime.now; - const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( - Effect.flatMap(decodeJsonString), - Effect.map(cast), - ); - yield* db - .insert(relayAgentActivityRows) - .values({ - environmentId: input.state.environmentId, - environmentPublicKey: input.environmentPublicKey, - threadId: input.state.threadId, + upsert: Effect.fn("relay.agent_activity_rows.upsert")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.state.environmentId, + "relay.thread_id": input.state.threadId, + }); + const now = yield* DateTime.now; + const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( + Effect.flatMap(decodeJsonString), + Effect.map(Function.cast), + Effect.mapError( + (cause) => + new AgentActivityRowUpsertPersistenceError({ + environmentId: input.state.environmentId, + threadId: input.state.threadId, + cause, + }), + ), + ); + yield* db + .insert(relayAgentActivityRows) + .values({ + environmentId: input.state.environmentId, + environmentPublicKey: input.environmentPublicKey, + threadId: input.state.threadId, + stateJson, + updatedAt: input.state.updatedAt, + createdAt: DateTime.formatIso(now), + }) + .onConflictDoUpdate({ + target: [ + relayAgentActivityRows.environmentId, + relayAgentActivityRows.environmentPublicKey, + relayAgentActivityRows.threadId, + ], + set: { stateJson, updatedAt: input.state.updatedAt, - createdAt: DateTime.formatIso(now), - }) - .onConflictDoUpdate({ - target: [ - relayAgentActivityRows.environmentId, - relayAgentActivityRows.environmentPublicKey, - relayAgentActivityRows.threadId, - ], - set: { - stateJson, - updatedAt: input.state.updatedAt, - }, - }); - }, - Effect.mapError((cause) => new AgentActivityRowUpsertPersistenceError({ cause })), - ), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowUpsertPersistenceError({ + environmentId: input.state.environmentId, + threadId: input.state.threadId, + cause, + }), + ), + ); + }), remove: Effect.fn("relay.agent_activity_rows.remove")(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -123,7 +151,16 @@ const make = Effect.gen(function* () { eq(relayAgentActivityRows.threadId, input.threadId), ), ) - .pipe(Effect.mapError((cause) => new AgentActivityRowDeletePersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowDeletePersistenceError({ + environmentId: input.environmentId, + threadId: input.threadId, + cause, + }), + ), + ); }), listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { @@ -157,7 +194,13 @@ const make = Effect.gen(function* () { Effect.map((rows) => rows.flatMap((row) => Option.toArray(decodeRelayAgentActivityStateJson(row))), ), - Effect.mapError((cause) => new AgentActivityRowListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new AgentActivityRowListPersistenceError({ + userId: input.userId, + cause, + }), + ), ); }), }); diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 1d327aa945b..bb557376862 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -1,13 +1,22 @@ +import * as NodeCrypto from "node:crypto"; + +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/unstable/http"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; -import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ApnsCredentials } from "../Config.ts"; import * as ApnsClient from "./ApnsClient.ts"; +const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError); +const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); + const TestLayer = ApnsClient.layer.pipe( Layer.provide( Layer.succeed( @@ -137,4 +146,112 @@ describe("ApnsClient", () => { }); }).pipe(Effect.provide(TestLayer)), ); + + it.effect("preserves JWT signing context and the crypto cause", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }, + }); + const error = yield* Effect.flip( + apns.sendPushNotificationRequest({ + credentials: { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make("not-a-private-key"), + bundleId: "com.t3tools.test", + environment: "sandbox", + }, + request, + issuedAtUnixSeconds: 123, + }), + ); + + expect(isApnsJwtSigningError(error)).toBe(true); + if (!isApnsJwtSigningError(error)) { + return yield* Effect.die("expected APNs JWT signing error"); + } + expect(error).toMatchObject({ + teamId: "team-1", + keyId: "key-1", + issuedAtUnixSeconds: 123, + cause: expect.any(Error), + message: "Failed to sign APNs JWT for key key-1.", + }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves APNs request context and the HTTP cause", () => { + const httpCause = new Error("network unavailable"); + const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const credentials = { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make(privateKey), + bundleId: "com.t3tools.test", + environment: "sandbox", + } satisfies ApnsCredentials; + const failingHttpClient = HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: httpCause }), + }), + ), + ); + const layer = ApnsClient.layer.pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), + ); + + return Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "long-push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }, + }); + const error = yield* Effect.flip( + apns.sendPushNotificationRequest({ + credentials, + request, + issuedAtUnixSeconds: 123, + }), + ); + + expect(isApnsHttpRequestError(error)).toBe(true); + if (!isApnsHttpRequestError(error)) { + return yield* Effect.die("expected APNs HTTP request error"); + } + expect(error).toMatchObject({ + requestKind: "push-notification", + event: null, + environment: "sandbox", + bundleId: "com.t3tools.test", + tokenSuffix: "sh-token", + stage: "send", + status: null, + message: "APNs push-notification request failed during send in sandbox.", + }); + expect(error.cause).toBeInstanceOf(HttpClientError.HttpClientError); + expect((error.cause as HttpClientError.HttpClientError).reason).toMatchObject({ + _tag: "TransportError", + cause: httpCause, + }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index a779085118d..1ac218cdd3c 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -8,15 +8,20 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; -import type { ApnsCredentials } from "../Config.ts"; +import * as Headers from "effect/unstable/http/Headers"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts"; import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; const STALE_AFTER_SECONDS = 2 * 60; const DISMISS_AFTER_SECONDS = 5 * 60; -export type ApnsLiveActivityEvent = "start" | "update" | "end"; +const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); +export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type; + +const ApnsRequestKindSchema = Schema.Literals(["live-activity", "push-notification"]); interface ApnsLiveActivityRequest { readonly token: string; @@ -38,41 +43,59 @@ export interface ApnsDeliveryResult { readonly apnsId: string | null; } -export class ApnsSigningError extends Schema.TaggedErrorClass()( - "ApnsSigningError", +export class ApnsJwtEncodingError extends Schema.TaggedErrorClass()( + "ApnsJwtEncodingError", { - phase: Schema.Literals(["encoding", "signing"]), + component: Schema.Literals(["header", "payload"]), + teamId: Schema.String, + keyId: Schema.String, + issuedAtUnixSeconds: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed during APNs JWT ${this.phase}`; + return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; } } -export class ApnsHttpRequestError extends Schema.TaggedErrorClass()( - "ApnsHttpRequestError", +export class ApnsJwtSigningError extends Schema.TaggedErrorClass()( + "ApnsJwtSigningError", { + teamId: Schema.String, + keyId: Schema.String, + issuedAtUnixSeconds: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return "APNs HTTP request failed"; + return `Failed to sign APNs JWT for key ${this.keyId}.`; } } -export class ApnsInvalidResponseError extends Schema.TaggedErrorClass()( - "ApnsInvalidResponseError", +export class ApnsHttpRequestError extends Schema.TaggedErrorClass()( + "ApnsHttpRequestError", { + requestKind: ApnsRequestKindSchema, + event: Schema.NullOr(ApnsLiveActivityEventSchema), + environment: ApnsEnvironmentSchema, + bundleId: Schema.String, + tokenSuffix: Schema.String, + stage: Schema.Literals(["send", "read-response"]), + status: Schema.NullOr(Schema.Number), cause: Schema.Defect(), }, ) { override get message(): string { - return "APNs returned an invalid response"; + return `APNs ${this.requestKind} request failed during ${this.stage} in ${this.environment}.`; } } -export type ApnsError = ApnsSigningError | ApnsHttpRequestError | ApnsInvalidResponseError; +export const ApnsError = Schema.Union([ + ApnsJwtEncodingError, + ApnsJwtSigningError, + ApnsHttpRequestError, +]); +export type ApnsError = typeof ApnsError.Type; const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( Schema.fromJsonString( @@ -105,12 +128,32 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { readonly issuedAtUnixSeconds: number; }) { const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( - Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" })), + Effect.mapError( + (cause) => + new ApnsJwtEncodingError({ + component: "header", + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + ), ); const payloadJson = yield* encodeApnsJwtPayloadJson({ iss: input.teamId, iat: input.issuedAtUnixSeconds, - }).pipe(Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" }))); + }).pipe( + Effect.mapError( + (cause) => + new ApnsJwtEncodingError({ + component: "payload", + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + ), + ); const privateKey = Redacted.value(input.privateKey); const header = Encoding.encodeBase64Url(headerJson); @@ -127,7 +170,13 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { }); return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; }, - catch: (cause) => new ApnsSigningError({ cause, phase: "signing" }), + catch: (cause) => + new ApnsJwtSigningError({ + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), }); }); @@ -231,29 +280,28 @@ function apnsReasonFromBody(body: string): string | undefined { }); } -export interface ApnsClientShape { - readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; - readonly makePushNotificationRequest: typeof makePushNotificationRequest; - readonly sendLiveActivityRequest: (input: { - readonly credentials: ApnsCredentials; - readonly request: ApnsLiveActivityRequest; - readonly issuedAtUnixSeconds: number; - }) => Effect.Effect; - readonly sendPushNotificationRequest: (input: { - readonly credentials: ApnsCredentials; - readonly request: ApnsPushNotificationRequest; - readonly issuedAtUnixSeconds: number; - }) => Effect.Effect; -} - -export class ApnsClient extends Context.Service()( - "t3code-relay/agentActivity/ApnsClient", -) {} +export class ApnsClient extends Context.Service< + ApnsClient, + { + readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; + readonly makePushNotificationRequest: typeof makePushNotificationRequest; + readonly sendLiveActivityRequest: (input: { + readonly credentials: ApnsCredentials; + readonly request: ApnsLiveActivityRequest; + readonly issuedAtUnixSeconds: number; + }) => Effect.Effect; + readonly sendPushNotificationRequest: (input: { + readonly credentials: ApnsCredentials; + readonly request: ApnsPushNotificationRequest; + readonly issuedAtUnixSeconds: number; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/ApnsClient") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; - const sendLiveActivityRequest: ApnsClientShape["sendLiveActivityRequest"] = Effect.fn( + const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( "relay.apns.send_live_activity_request", )(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); @@ -274,10 +322,34 @@ const make = Effect.gen(function* () { }), HttpClientRequest.bodyJson(input.request.payload), Effect.flatMap(httpClient.execute), - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + Effect.mapError( + (cause) => + new ApnsHttpRequestError({ + requestKind: "live-activity", + event: input.request.event, + environment: input.credentials.environment, + bundleId: input.credentials.bundleId, + tokenSuffix: input.request.token.slice(-8), + stage: "send", + status: null, + cause, + }), + ), ); const responseText = yield* response.text.pipe( - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + Effect.mapError( + (cause) => + new ApnsHttpRequestError({ + requestKind: "live-activity", + event: input.request.event, + environment: input.credentials.environment, + bundleId: input.credentials.bundleId, + tokenSuffix: input.request.token.slice(-8), + stage: "read-response", + status: response.status, + cause, + }), + ), ); const reason = apnsReasonFromBody(responseText); return { @@ -288,40 +360,65 @@ const make = Effect.gen(function* () { }; }); - const sendPushNotificationRequest: ApnsClientShape["sendPushNotificationRequest"] = Effect.fn( - "relay.apns.send_push_notification_request", - )(function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); - const jwt = yield* makeApnsJwt({ - ...input.credentials, - issuedAtUnixSeconds: input.issuedAtUnixSeconds, + const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] = + Effect.fn("relay.apns.send_push_notification_request")(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); + const jwt = yield* makeApnsJwt({ + ...input.credentials, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + }); + const host = + input.credentials.environment === "production" + ? "https://api.push.apple.com" + : "https://api.sandbox.push.apple.com"; + const response = yield* HttpClientRequest.post( + `${host}/3/device/${input.request.token}`, + ).pipe( + HttpClientRequest.setHeaders({ + authorization: `bearer ${jwt}`, + "apns-priority": input.request.priority, + "apns-push-type": "alert", + "apns-topic": input.credentials.bundleId, + }), + HttpClientRequest.bodyJson(input.request.payload), + Effect.flatMap(httpClient.execute), + Effect.mapError( + (cause) => + new ApnsHttpRequestError({ + requestKind: "push-notification", + event: null, + environment: input.credentials.environment, + bundleId: input.credentials.bundleId, + tokenSuffix: input.request.token.slice(-8), + stage: "send", + status: null, + cause, + }), + ), + ); + const responseText = yield* response.text.pipe( + Effect.mapError( + (cause) => + new ApnsHttpRequestError({ + requestKind: "push-notification", + event: null, + environment: input.credentials.environment, + bundleId: input.credentials.bundleId, + tokenSuffix: input.request.token.slice(-8), + stage: "read-response", + status: response.status, + cause, + }), + ), + ); + const reason = apnsReasonFromBody(responseText); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + ...(reason === undefined ? {} : { reason }), + apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), + }; }); - const host = - input.credentials.environment === "production" - ? "https://api.push.apple.com" - : "https://api.sandbox.push.apple.com"; - const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( - HttpClientRequest.setHeaders({ - authorization: `bearer ${jwt}`, - "apns-priority": input.request.priority, - "apns-push-type": "alert", - "apns-topic": input.credentials.bundleId, - }), - HttpClientRequest.bodyJson(input.request.payload), - Effect.flatMap(httpClient.execute), - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), - ); - const responseText = yield* response.text.pipe( - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), - ); - const reason = apnsReasonFromBody(responseText); - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - ...(reason === undefined ? {} : { reason }), - apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), - }; - }); return ApnsClient.of({ makeLiveActivityRequest, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 0dfee1fb0cd..da3c39cfa71 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -7,7 +7,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Redacted from "effect/Redacted"; +import * as References from "effect/References"; import { FetchHttpClient, HttpClient, @@ -141,19 +143,19 @@ function makeLayer(input: { readonly sourceJobClaims?: ReadonlyMap; readonly queuedJobs?: Array; readonly queuedStarts?: Array< - Parameters[0] + Parameters[0] >; readonly clearedStarts?: Array< - Parameters[0] + Parameters[0] >; readonly markedDeliveries?: Array< - Parameters[0] + Parameters[0] >; readonly invalidatedTokens?: Array< - Parameters[0] + Parameters[0] >; readonly currentTargets?: ReadonlyArray; - readonly config?: RelayConfiguration.RelayConfigurationShape; + readonly config?: RelayConfiguration.RelayConfiguration["Service"]; readonly execute?: ( request: HttpClientRequest.HttpClientRequest, ) => Effect.Effect; @@ -213,7 +215,7 @@ function makeLayer(input: { input.invalidatedTokens?.push(invalidated); }), }), - Layer.succeed(RelayConfiguration.RelayConfiguration, input.config ?? config), + RelayConfiguration.layer(input.config ?? config), input.execute ? Layer.succeed(HttpClient.HttpClient, HttpClient.make(input.execute)) : FetchHttpClient.layer, @@ -227,10 +229,10 @@ describe("ApnsDeliveries", () => { const attempts: Array = []; const queuedJobs: Array = []; const queuedStarts: Array< - Parameters[0] + Parameters[0] > = []; const markedDeliveries: Array< - Parameters[0] + Parameters[0] > = []; return Effect.gen(function* () { @@ -611,8 +613,35 @@ describe("ApnsDeliveries", () => { }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); }); + it.effect("preserves the schema cause for invalid queue payloads", () => { + const attempts: Array = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const error = yield* Effect.flip(deliveries.processSignedJob({ invalid: true })); + + expect(error).toMatchObject({ + _tag: "ApnsDeliveryJobQueuePayloadInvalid", + receivedType: "object", + message: "Invalid APNs delivery queue job with object payload.", + }); + expect(error.cause).toMatchObject({ _tag: "SchemaError" }); + }).pipe(Effect.provide(makeLayer({ attempts }))); + }); + it.effect("processes signed jobs through APNs and records attempts", () => { const attempts: Array = []; + const transportErrors: Array = []; + const logger = Logger.make(({ fiber }) => { + const annotation = fiber.getRef(References.CurrentLogAnnotations).error; + if (!Redacted.isRedacted(annotation)) { + return; + } + const error = Redacted.value(annotation); + if (ApnsDeliveries.isApnsDeliveryTransportError(error)) { + transportErrors.push(error); + } + }); const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_update", userId: target.user_id, @@ -641,7 +670,29 @@ describe("ApnsDeliveries", () => { token: "activity-token", }, ]); - }).pipe(Effect.provide(makeLayer({ attempts }))); + expect(transportErrors).toHaveLength(1); + const error = transportErrors[0]!; + expect(error).toMatchObject({ + deviceId: target.device_id, + kind: "live_activity_update", + sourceJobId: "job-1", + apnsErrorTag: "ApnsJwtSigningError", + requestStage: null, + }); + expect(error.cause).toBeInstanceOf(ApnsClient.ApnsJwtSigningError); + expect(error.cause).toMatchObject({ + teamId: "team-id", + keyId: "key-id", + }); + expect((error.cause as ApnsClient.ApnsJwtSigningError).cause).toBeDefined(); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeLayer({ attempts }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); }); it.effect("processes signed push notification jobs through APNs and records attempts", () => { @@ -933,7 +984,7 @@ describe("ApnsDeliveries", () => { it.effect("invalidates dead device push tokens after permanent APNs alert failures", () => { const attempts: Array = []; const invalidatedTokens: Array< - Parameters[0] + Parameters[0] > = []; const payload = makeApnsDeliveryJobPayload({ kind: "push_notification", @@ -1000,7 +1051,7 @@ describe("ApnsDeliveries", () => { it.effect("clears queued start state when a start job fails in APNs", () => { const attempts: Array = []; const clearedStarts: Array< - Parameters[0] + Parameters[0] > = []; const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_start", @@ -1035,7 +1086,7 @@ describe("ApnsDeliveries", () => { it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => { const attempts: Array = []; const invalidatedTokens: Array< - Parameters[0] + Parameters[0] > = []; const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_start", @@ -1082,7 +1133,7 @@ describe("ApnsDeliveries", () => { it.effect("invalidates dead Live Activity tokens after APNs unregisters them", () => { const attempts: Array = []; const invalidatedTokens: Array< - Parameters[0] + Parameters[0] > = []; const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_update", diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index c1dba1467fa..c83eaf34f2e 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -7,12 +7,14 @@ import type { import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema, RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema, + RelayDeliveryKind as RelayDeliveryKindSchema, } from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { @@ -21,9 +23,12 @@ import { } from "./agentActivityPayloads.ts"; import * as Apns from "./ApnsClient.ts"; import { - ApnsDeliveryJobInvalid, + ApnsDeliveryJobLiveActivityAggregateMissing, + ApnsDeliveryJobPushNotificationMissing, + ApnsDeliveryJobQueuePayloadInvalid, type ApnsNotificationPayload, SignedApnsDeliveryJob, + isApnsDeliveryJobVerificationError, verifySignedApnsDeliveryJob, type ApnsDeliveryJobVerificationError, } from "./apnsDeliveryJobs.ts"; @@ -84,6 +89,28 @@ export class ApnsDeliveryJobClaimInFlight extends Schema.TaggedErrorClass()( + "ApnsDeliveryTransportError", + { + deviceId: Schema.String, + kind: RelayDeliveryKindSchema, + sourceJobId: Schema.NullOr(Schema.String), + apnsErrorTag: Schema.Literals([ + "ApnsJwtEncodingError", + "ApnsJwtSigningError", + "ApnsHttpRequestError", + ]), + requestStage: Schema.NullOr(Schema.Literals(["send", "read-response"])), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `APNs ${this.kind} delivery failed for device ${this.deviceId}.`; + } +} + +export const isApnsDeliveryTransportError = Schema.is(ApnsDeliveryTransportError); + const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), ); @@ -92,17 +119,6 @@ const decodeRelayAgentAwarenessPreferencesJson = Schema.decodeUnknownOption( ); const decodeSignedApnsDeliveryJob = Schema.decodeUnknownEffect(SignedApnsDeliveryJob); -function apnsErrorMessage(error: Apns.ApnsError): string { - switch (error._tag) { - case "ApnsSigningError": - return "Failed to sign APNs request."; - case "ApnsHttpRequestError": - return "Failed to send APNs request."; - case "ApnsInvalidResponseError": - return "APNs returned an invalid response."; - } -} - function parseAggregate(value: string | null): RelayAgentActivityAggregateState | null { if (!value) { return null; @@ -273,15 +289,6 @@ function isPermanentApnsTokenFailure(result: Apns.ApnsDeliveryResult): boolean { ); } -function isDeliveryJobVerificationError(value: unknown): value is ApnsDeliveryJobVerificationError { - return ( - typeof value === "object" && - value !== null && - "_tag" in value && - (value._tag === "ApnsDeliveryJobInvalid" || value._tag === "ApnsDeliveryJobExpired") - ); -} - function duplicateJobResult(input: { readonly deviceId: string; readonly kind: RelayDeliveryKind; @@ -319,6 +326,42 @@ function deliveryAttemptOutcome(result: Apns.ApnsDeliveryResult) { }; } +const recoverApnsDeliveryTransportError = ( + input: { + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly sourceJobId: string | null; + }, + cause: Apns.ApnsError, +): Effect.Effect => { + const error = new ApnsDeliveryTransportError({ + deviceId: input.deviceId, + kind: input.kind, + sourceJobId: input.sourceJobId, + apnsErrorTag: cause._tag, + requestStage: cause._tag === "ApnsHttpRequestError" ? cause.stage : null, + cause, + }); + return Effect.logError(error.message).pipe( + Effect.annotateLogs({ + error: Redacted.make(error, { label: error._tag }), + "error.type": error._tag, + "error.apns_error_tag": error.apnsErrorTag, + ...(error.requestStage === null ? {} : { "error.request_stage": error.requestStage }), + ...(error.stack === undefined ? {} : { "error.stack": error.stack }), + "relay.mobile.device_id": error.deviceId, + "relay.delivery.kind": error.kind, + ...(error.sourceJobId === null ? {} : { "relay.delivery.job_id": error.sourceJobId }), + }), + Effect.as({ + ok: false, + status: 0, + reason: cause.message, + apnsId: null, + }), + ); +}; + interface LiveActivityDeliveryTarget { readonly user_id: string; readonly device_id: string; @@ -356,7 +399,7 @@ export type SendLiveActivityDeliveryInput = }); function makeLiveActivityDeliveryRequest( - apns: Apns.ApnsClientShape, + apns: Apns.ApnsClient["Service"], input: SendLiveActivityDeliveryInput, now: DateTime.DateTime, ) { @@ -391,35 +434,34 @@ function makeLiveActivityDeliveryRequest( } } -export interface ApnsDeliveriesShape { - readonly sendForTarget: (input: { - readonly target: LiveActivities.TargetRow; - readonly aggregate: RelayAgentActivityAggregateState | null; - readonly nowMs: number; - }) => Effect.Effect; - readonly sendPushNotificationForTarget: (input: { - readonly target: LiveActivities.TargetRow; - readonly aggregate: RelayAgentActivityAggregateState | null; - }) => Effect.Effect; - readonly sendLiveActivity: ( - input: SendLiveActivityDeliveryInput, - ) => Effect.Effect; - readonly processSignedJob: ( - body: unknown, - ) => Effect.Effect; - readonly sendPushNotification: (input: { - readonly target: LiveActivityDeliveryTarget; - readonly token: string; - readonly sourceJobId?: string | null; - readonly notification: ApnsNotificationPayload; - }) => Effect.Effect; -} - -export class ApnsDeliveries extends Context.Service()( - "t3code-relay/agentActivity/ApnsDeliveries", -) {} +export class ApnsDeliveries extends Context.Service< + ApnsDeliveries, + { + readonly sendForTarget: (input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly nowMs: number; + }) => Effect.Effect; + readonly sendPushNotificationForTarget: (input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; + }) => Effect.Effect; + readonly sendLiveActivity: ( + input: SendLiveActivityDeliveryInput, + ) => Effect.Effect; + readonly processSignedJob: ( + body: unknown, + ) => Effect.Effect; + readonly sendPushNotification: (input: { + readonly target: LiveActivityDeliveryTarget; + readonly token: string; + readonly sourceJobId?: string | null; + readonly notification: ApnsNotificationPayload; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/ApnsDeliveries") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; const liveActivities = yield* LiveActivities.LiveActivities; const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; @@ -442,7 +484,7 @@ const make = Effect.gen(function* () { ); }); - const sendLiveActivity: ApnsDeliveriesShape["sendLiveActivity"] = Effect.fn( + const sendLiveActivity: ApnsDeliveries["Service"]["sendLiveActivity"] = Effect.fn( "relay.apns_deliveries.send_live_activity", )(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -458,6 +500,15 @@ const make = Effect.gen(function* () { { ...input, aggregate } as SendLiveActivityDeliveryInput, now, ); + const recoverTransportError = (cause: Apns.ApnsError) => + recoverApnsDeliveryTransportError( + { + deviceId: input.target.device_id, + kind: input.kind, + sourceJobId: input.sourceJobId ?? null, + }, + cause, + ); if (input.sourceJobId) { const claim = yield* attempts.claimSourceJob({ userId: input.target.user_id, @@ -494,14 +545,11 @@ const make = Effect.gen(function* () { issuedAtUnixSeconds: epochSeconds, }) .pipe( - Effect.catch((error) => - Effect.succeed({ - ok: false, - status: 0, - reason: apnsErrorMessage(error), - apnsId: null, - }), - ), + Effect.catchTags({ + ApnsJwtEncodingError: recoverTransportError, + ApnsJwtSigningError: recoverTransportError, + ApnsHttpRequestError: recoverTransportError, + }), ); if (result.ok) { yield* liveActivities.markDelivery({ @@ -550,7 +598,7 @@ const make = Effect.gen(function* () { }; }); - const sendPushNotification: ApnsDeliveriesShape["sendPushNotification"] = Effect.fn( + const sendPushNotification: ApnsDeliveries["Service"]["sendPushNotification"] = Effect.fn( "relay.apns_deliveries.send_push_notification", )(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -569,6 +617,15 @@ const make = Effect.gen(function* () { token: input.token, notification, }); + const recoverTransportError = (cause: Apns.ApnsError) => + recoverApnsDeliveryTransportError( + { + deviceId: input.target.device_id, + kind: "push_notification", + sourceJobId: input.sourceJobId ?? null, + }, + cause, + ); if (input.sourceJobId) { const claim = yield* attempts.claimSourceJob({ userId: input.target.user_id, @@ -611,14 +668,11 @@ const make = Effect.gen(function* () { issuedAtUnixSeconds: epochSeconds, }) .pipe( - Effect.catch((error) => - Effect.succeed({ - ok: false, - status: 0, - reason: apnsErrorMessage(error), - apnsId: null, - }), - ), + Effect.catchTags({ + ApnsJwtEncodingError: recoverTransportError, + ApnsJwtSigningError: recoverTransportError, + ApnsHttpRequestError: recoverTransportError, + }), ); if (isPermanentApnsTokenFailure(result)) { yield* liveActivities.invalidateDeliveryToken({ @@ -654,14 +708,15 @@ const make = Effect.gen(function* () { }; }); - const processSignedJob: ApnsDeliveriesShape["processSignedJob"] = Effect.fn( + const processSignedJob: ApnsDeliveries["Service"]["processSignedJob"] = Effect.fn( "relay.apns_deliveries.process_signed_job", )(function* (body) { const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( Effect.mapError( - () => - new ApnsDeliveryJobInvalid({ - message: "Invalid APNs delivery queue job.", + (cause) => + new ApnsDeliveryJobQueuePayloadInvalid({ + receivedType: Array.isArray(body) ? "array" : body === null ? "null" : typeof body, + cause, }), ), ); @@ -671,7 +726,7 @@ const make = Effect.gen(function* () { job: signedJob, nowMs: now.epochMilliseconds, }); - if (isDeliveryJobVerificationError(payload)) { + if (isApnsDeliveryJobVerificationError(payload)) { return yield* payload; } yield* Effect.annotateCurrentSpan({ @@ -685,8 +740,11 @@ const make = Effect.gen(function* () { case "live_activity_update": if (payload.aggregate === null) { return Effect.fail( - new ApnsDeliveryJobInvalid({ - message: "Live Activity start/update jobs require an aggregate.", + new ApnsDeliveryJobLiveActivityAggregateMissing({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, }), ); } @@ -714,8 +772,10 @@ const make = Effect.gen(function* () { case "push_notification": if (payload.notification === null) { return Effect.fail( - new ApnsDeliveryJobInvalid({ - message: "Push notification jobs require a notification payload.", + new ApnsDeliveryJobPushNotificationMissing({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, }), ); } diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts new file mode 100644 index 00000000000..b3a8083efe8 --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts @@ -0,0 +1,79 @@ +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as RelayConfiguration from "../Config.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; + +const config: RelayConfiguration.RelayConfiguration["Service"] = { + relayIssuer: "https://relay.example.com", + apns: { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make("apns-private-key"), + bundleId: "com.t3tools.test", + environment: "sandbox", + }, + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"), + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}; + +describe("ApnsDeliveryQueue", () => { + it.effect("preserves job identity and the queue sender cause", () => { + const cause = new Error("queue unavailable"); + const senderCause = new Cloudflare.QueueSendError({ + message: cause.message, + cause, + }); + const layer = ApnsDeliveryQueue.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(RelayConfiguration.layer(config)), + Layer.provide( + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { + send: () => Effect.fail(senderCause), + }), + ), + ); + + return Effect.gen(function* () { + const queue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; + const error = yield* Effect.flip( + queue.enqueuePushNotification({ + userId: "user-1", + deviceId: "device-1", + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env-1", + threadId: "thread-1", + deepLink: "/threads/env-1/thread-1", + }, + }), + ); + + expect(error).toMatchObject({ + _tag: "ApnsDeliveryQueueSendError", + operation: "send", + jobId: expect.any(String), + kind: "push_notification", + userId: "user-1", + deviceId: "device-1", + cause: senderCause, + }); + expect(senderCause.cause).toBe(cause); + expect(error.message).toBe( + "Failed to enqueue APNs push notification delivery during send for device device-1.", + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 3582e236b4d..6c1fd79dc1c 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -7,7 +7,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import type { RelayDeliveryResult } from "@t3tools/contracts/relay"; +import { + RelayDeliveryKind as RelayDeliveryKindSchema, + type RelayDeliveryResult, +} from "@t3tools/contracts/relay"; import { sanitizeAgentActivityAggregateState, @@ -24,45 +27,49 @@ import * as RelayConfiguration from "../Config.ts"; export class ApnsDeliveryQueueSendError extends Schema.TaggedErrorClass()( "ApnsDeliveryQueueSendError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["generate-job-id", "send"]), + jobId: Schema.NullOr(Schema.String), + kind: RelayDeliveryKindSchema, + userId: Schema.String, + deviceId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to enqueue APNs delivery"; + return `Failed to enqueue APNs ${this.kind.replaceAll("_", " ")} delivery during ${this.operation} for device ${this.deviceId}.`; } } export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError; -export interface ApnsDeliveryQueueSenderShape { - readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect; -} - export class ApnsDeliveryQueueSender extends Context.Service< ApnsDeliveryQueueSender, - ApnsDeliveryQueueSenderShape + { + readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect; + } >()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {} -export interface ApnsDeliveryQueueShape { - readonly enqueueLiveActivity: (input: { - readonly kind: ApnsDeliveryJobPayload["kind"]; - readonly userId: string; - readonly deviceId: string; - readonly token: string; - readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; - }) => Effect.Effect; - readonly enqueuePushNotification: (input: { - readonly userId: string; - readonly deviceId: string; - readonly token: string; - readonly notification: NonNullable; - }) => Effect.Effect; -} - -export class ApnsDeliveryQueue extends Context.Service()( - "t3code-relay/agentActivity/ApnsDeliveryQueue", -) {} +export class ApnsDeliveryQueue extends Context.Service< + ApnsDeliveryQueue, + { + readonly enqueueLiveActivity: (input: { + readonly kind: ApnsDeliveryJobPayload["kind"]; + readonly userId: string; + readonly deviceId: string; + readonly token: string; + readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; + }) => Effect.Effect; + readonly enqueuePushNotification: (input: { + readonly userId: string; + readonly deviceId: string; + readonly token: string; + readonly notification: NonNullable; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/ApnsDeliveryQueue") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const sender = yield* ApnsDeliveryQueueSender; const crypto = yield* Crypto.Crypto; const config = yield* RelayConfiguration.RelayConfiguration; @@ -76,7 +83,17 @@ const make = Effect.gen(function* () { }); const now = yield* DateTime.now; const jobId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "generate-job-id", + jobId: null, + kind: input.kind, + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), ); yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); const payload = makeApnsDeliveryJobPayload({ @@ -91,7 +108,19 @@ const make = Effect.gen(function* () { secret: config.apnsDeliveryJobSigningSecret, payload, }); - yield* sender.send(signed); + yield* sender.send(signed).pipe( + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "send", + jobId, + kind: input.kind, + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), + ); return { deviceId: input.deviceId, kind: input.kind, @@ -113,7 +142,17 @@ const make = Effect.gen(function* () { }); const now = yield* DateTime.now; const jobId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "generate-job-id", + jobId: null, + kind: "push_notification", + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), ); yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); const payload = makeApnsDeliveryJobPayload({ @@ -131,7 +170,19 @@ const make = Effect.gen(function* () { secret: config.apnsDeliveryJobSigningSecret, payload, }); - yield* sender.send(signed); + yield* sender.send(signed).pipe( + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "send", + jobId, + kind: "push_notification", + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), + ); return { deviceId: input.deviceId, kind: "push_notification" as const, @@ -158,10 +209,9 @@ export const layerCloudflareQueues = ( ApnsDeliveryQueueSender, ApnsDeliveryQueueSender.of({ send: (body) => - sender.send(body).pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), - ), + sender + .send(body) + .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)), }), ), ), diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts index 81abb330726..2f3d948983d 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDeliveryAttempts } from "../persistence/schema.ts"; import * as DeliveryAttempts from "./DeliveryAttempts.ts"; @@ -20,7 +20,7 @@ describe("DeliveryAttempts", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -52,7 +52,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -78,7 +78,7 @@ describe("DeliveryAttempts", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -104,7 +104,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -135,7 +135,7 @@ describe("DeliveryAttempts", () => { }), }), }), - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -154,7 +154,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -185,7 +185,7 @@ describe("DeliveryAttempts", () => { }), }), }), - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -204,7 +204,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -246,7 +246,7 @@ describe("DeliveryAttempts", () => { }; }, }), - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -266,7 +266,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -290,7 +290,7 @@ describe("DeliveryAttempts", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -315,7 +315,98 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("preserves operation context and causes for persistence failures", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + insert: () => ({ + values: (values: Record) => + values.kind === "record" + ? Effect.fail(cause) + : { + onConflictDoNothing: () => ({ + returning: () => Effect.fail(cause), + }), + }, + }), + update: () => ({ + set: () => ({ + where: () => Effect.fail(cause), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + const recordError = yield* Effect.flip( + attempts.record({ + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "record", + sourceJobId: "job-1", + token: "apns-token", + }), + ); + const claimError = yield* Effect.flip( + attempts.claimSourceJob({ + userId: "user-2", + environmentId: "env-2", + threadId: "thread-2", + deviceId: "device-2", + kind: "claim", + sourceJobId: "job-2", + token: "apns-token", + }), + ); + const completionError = yield* Effect.flip( + attempts.completeSourceJob({ sourceJobId: "job-3", apnsStatus: 500 }), + ); + + expect(recordError).toMatchObject({ + operation: "record", + sourceJobId: "job-1", + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "record", + cause, + message: "Failed to persist APNs delivery attempt during record.", + }); + expect(claimError).toMatchObject({ + operation: "claim-source-job", + sourceJobId: "job-2", + userId: "user-2", + environmentId: "env-2", + threadId: "thread-2", + deviceId: "device-2", + kind: "claim", + cause, + message: "Failed to persist APNs delivery attempt during claim-source-job.", + }); + expect(completionError).toMatchObject({ + operation: "complete-source-job", + sourceJobId: "job-3", + userId: null, + environmentId: null, + threadId: null, + deviceId: null, + kind: null, + cause, + message: "Failed to persist APNs delivery attempt during complete-source-job.", + }); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index b88e5c82c51..843415abfe8 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -7,15 +7,24 @@ import { and, eq, isNull } from "drizzle-orm"; import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDeliveryAttempts } from "../persistence/schema.ts"; export class DeliveryAttemptRecordPersistenceError extends Schema.TaggedErrorClass()( "DeliveryAttemptRecordPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["record", "claim-source-job", "complete-source-job"]), + sourceJobId: Schema.NullOr(Schema.String), + userId: Schema.NullOr(Schema.String), + environmentId: Schema.NullOr(Schema.String), + threadId: Schema.NullOr(Schema.String), + deviceId: Schema.NullOr(Schema.String), + kind: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist APNs delivery attempt"; + return `Failed to persist APNs delivery attempt during ${this.operation}.`; } } @@ -43,21 +52,20 @@ export interface DeliveryAttemptCompletionInput { export type DeliverySourceJobClaimResult = "claimed" | "completed" | "in_flight"; -export interface DeliveryAttemptsShape { - readonly record: ( - input: DeliveryAttemptInput, - ) => Effect.Effect; - readonly claimSourceJob: ( - input: DeliveryAttemptInput & { readonly sourceJobId: string }, - ) => Effect.Effect; - readonly completeSourceJob: ( - input: DeliveryAttemptCompletionInput, - ) => Effect.Effect; -} - -export class DeliveryAttempts extends Context.Service()( - "t3code-relay/agentActivity/DeliveryAttempts", -) {} +export class DeliveryAttempts extends Context.Service< + DeliveryAttempts, + { + readonly record: ( + input: DeliveryAttemptInput, + ) => Effect.Effect; + readonly claimSourceJob: ( + input: DeliveryAttemptInput & { readonly sourceJobId: string }, + ) => Effect.Effect; + readonly completeSourceJob: ( + input: DeliveryAttemptCompletionInput, + ) => Effect.Effect; + } +>()("t3code-relay/agentActivity/DeliveryAttempts") {} const SOURCE_JOB_CLAIM_LEASE_MINUTES = 10; @@ -83,8 +91,8 @@ function insertValues( }; } -const make = Effect.gen(function* () { - const db = yield* RelayDb; +export const make = Effect.gen(function* () { + const db = yield* RelayDb.RelayDb; const crypto = yield* Crypto.Crypto; const isExpiredClaim = (claimedAt: string | null, now: DateTime.DateTime) => { @@ -100,30 +108,43 @@ const make = Effect.gen(function* () { }; return DeliveryAttempts.of({ - record: Effect.fn("relay.delivery_attempts.record")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.delivery.kind": input.kind, - ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), - ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), - ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), - ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), - }); + record: Effect.fn("relay.delivery_attempts.record")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.delivery.kind": input.kind, + ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), + ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), + }); + yield* Effect.gen(function* () { const id = yield* crypto.randomUUIDv4; const createdAt = DateTime.formatIso(yield* DateTime.now); yield* db.insert(relayDeliveryAttempts).values(insertValues(input, id, createdAt)); - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), - claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.delivery.kind": input.kind, - "relay.delivery.job_id": input.sourceJobId, - ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), - ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), - ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), - }); + }).pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "record", + sourceJobId: input.sourceJobId ?? null, + userId: input.userId, + environmentId: input.environmentId, + threadId: input.threadId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); + }), + claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.delivery.kind": input.kind, + "relay.delivery.job_id": input.sourceJobId, + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), + ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), + }); + return yield* Effect.gen(function* () { const id = yield* crypto.randomUUIDv4; const now = yield* DateTime.now; const createdAt = DateTime.formatIso(now); @@ -180,26 +201,51 @@ const make = Effect.gen(function* () { ) .returning({ id: relayDeliveryAttempts.id }); return reclaimed.length > 0 ? "claimed" : "in_flight"; - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), - completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId }); - const completedAt = DateTime.formatIso(yield* DateTime.now); - yield* db - .update(relayDeliveryAttempts) - .set({ - createdAt: completedAt, - apnsStatus: input.apnsStatus ?? null, - apnsReason: input.apnsReason ?? null, - apnsId: input.apnsId ?? null, - transportError: input.transportError ?? null, - }) - .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)); - }, - Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), - ), + }).pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "claim-source-job", + sourceJobId: input.sourceJobId, + userId: input.userId, + environmentId: input.environmentId, + threadId: input.threadId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); + }), + completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId }); + const completedAt = DateTime.formatIso(yield* DateTime.now); + yield* db + .update(relayDeliveryAttempts) + .set({ + createdAt: completedAt, + apnsStatus: input.apnsStatus ?? null, + apnsReason: input.apnsReason ?? null, + apnsId: input.apnsId ?? null, + transportError: input.transportError ?? null, + }) + .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)) + .pipe( + Effect.mapError( + (cause) => + new DeliveryAttemptRecordPersistenceError({ + operation: "complete-source-job", + sourceJobId: input.sourceJobId, + userId: null, + environmentId: null, + threadId: null, + deviceId: null, + kind: null, + cause, + }), + ), + ); + }), }); }); diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index 7a3b227703f..553899da178 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -5,7 +5,7 @@ import { PgDialect } from "drizzle-orm/pg-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; import * as Devices from "./Devices.ts"; @@ -71,7 +71,7 @@ describe("Devices", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const devices = yield* Devices.Devices; @@ -110,7 +110,9 @@ describe("Devices", () => { pushToStartToken: "push-to-start-token", }), ]); - }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); it.effect("unregisters APNs state only for the current user device", () => { @@ -130,7 +132,7 @@ describe("Devices", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const devices = yield* Devices.Devices; @@ -156,7 +158,9 @@ describe("Devices", () => { params: ["user-2", "device-1"], }, ]); - }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); it.effect("lists safe notification state without exposing APNs tokens", () => { @@ -184,7 +188,7 @@ describe("Devices", () => { }; }, }), - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const devices = yield* Devices.Devices; @@ -215,6 +219,86 @@ describe("Devices", () => { updatedAt: "2026-06-01T00:00:00.000Z", }, ]); - }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); + + it.effect("identifies the failed device registration stage", () => { + const cause = new Error("push-token claim failed"); + const fakeDb = { + update: () => ({ + set: (values: Record) => ({ + where: () => ("pushToken" in values ? Effect.fail(cause) : Effect.void), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const error = yield* devices.register({ userId: "user-2", registration }).pipe(Effect.flip); + + expect(error).toMatchObject({ + userId: "user-2", + deviceId: "device-1", + stage: "claim-push-token", + }); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Failed to persist mobile device registration for user-2/device-1 during claim-push-token.", + ); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); + + it.effect("identifies the failed device unregistration stage", () => { + const cause = new Error("live activity delete failed"); + const fakeDb = { + delete: (table: unknown) => ({ + where: () => (table === relayLiveActivities ? Effect.fail(cause) : Effect.void), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const error = yield* devices + .unregister({ userId: "user-2", deviceId: "device-1" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + userId: "user-2", + deviceId: "device-1", + stage: "delete-live-activity", + }); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Failed to unregister mobile device user-2/device-1 during delete-live-activity.", + ); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); + + it.effect("attaches the user to device list failures", () => { + const cause = new Error("device list failed"); + const fakeDb = { + select: () => ({ + from: () => ({ + where: () => Effect.fail(cause), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const error = yield* devices.listForUser({ userId: "user-2" }).pipe(Effect.flip); + + expect(error).toMatchObject({ userId: "user-2" }); + expect(error.cause).toBe(cause); + expect(error.message).toBe("Failed to list mobile devices for user-2."); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); }); diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 86c338b0912..86e3564d5be 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -10,182 +10,245 @@ import * as Schema from "effect/Schema"; import { and, eq } from "drizzle-orm"; import { sql } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; export class DeviceRegistrationPersistenceError extends Schema.TaggedErrorClass()( "DeviceRegistrationPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + deviceId: Schema.String, + stage: Schema.Literals(["claim-push-token", "claim-push-to-start-token", "upsert-device"]), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist mobile device registration"; + return `Failed to persist mobile device registration for ${this.userId}/${this.deviceId} during ${this.stage}.`; } } export class DeviceUnregistrationPersistenceError extends Schema.TaggedErrorClass()( "DeviceUnregistrationPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + deviceId: Schema.String, + stage: Schema.Literals(["delete-live-activity", "delete-device"]), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to unregister mobile device"; + return `Failed to unregister mobile device ${this.userId}/${this.deviceId} during ${this.stage}.`; } } export class DeviceListPersistenceError extends Schema.TaggedErrorClass()( "DeviceListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list mobile devices"; + return `Failed to list mobile devices for ${this.userId}.`; } } -export interface DevicesShape { - readonly register: (input: { - readonly userId: string; - readonly registration: RelayDeviceRegistrationRequest; - }) => Effect.Effect; - readonly unregister: (input: { - readonly userId: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly listForUser: (input: { - readonly userId: string; - }) => Effect.Effect, DeviceListPersistenceError>; -} - -export class Devices extends Context.Service()( - "t3code-relay/agentActivity/Devices", -) {} +export class Devices extends Context.Service< + Devices, + { + readonly register: (input: { + readonly userId: string; + readonly registration: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregister: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect, DeviceListPersistenceError>; + } +>()("t3code-relay/agentActivity/Devices") {} -const make = Effect.gen(function* () { - const db = yield* RelayDb; +export const make = Effect.gen(function* () { + const db = yield* RelayDb.RelayDb; return Devices.of({ - register: Effect.fn("relay.devices.register")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.mobile.device_id": input.registration.deviceId, - }); - const updatedAt = DateTime.formatIso(yield* DateTime.now); - const registration = input.registration; + register: Effect.fn("relay.devices.register")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.registration.deviceId, + }); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + const registration = input.registration; - yield* Effect.all( - [ - registration.pushToken - ? db - .update(relayMobileDevices) - .set({ pushToken: null, updatedAt }) - .where(eq(relayMobileDevices.pushToken, registration.pushToken)) - : Effect.void, - registration.pushToStartToken - ? db - .update(relayMobileDevices) - .set({ pushToStartToken: null, updatedAt }) - .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) - : Effect.void, - ], - { concurrency: 2, discard: true }, - ); + yield* Effect.all( + [ + registration.pushToken + ? db + .update(relayMobileDevices) + .set({ pushToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToken, registration.pushToken)) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-push-token", + cause, + }), + ), + ) + : Effect.void, + registration.pushToStartToken + ? db + .update(relayMobileDevices) + .set({ pushToStartToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-push-to-start-token", + cause, + }), + ), + ) + : Effect.void, + ], + { discard: true }, + ); - yield* db - .insert(relayMobileDevices) - .values({ - userId: input.userId, - deviceId: registration.deviceId, - label: registration.label, + yield* db + .insert(relayMobileDevices) + .values({ + userId: input.userId, + deviceId: registration.deviceId, + label: registration.label, + platform: registration.platform, + iosMajorVersion: registration.iosMajorVersion, + appVersion: registration.appVersion ?? null, + pushToken: registration.pushToken ?? null, + pushToStartToken: registration.pushToStartToken ?? null, + preferencesJson: registration.preferences, + createdAt: updatedAt, + updatedAt, + }) + .onConflictDoUpdate({ + target: [relayMobileDevices.userId, relayMobileDevices.deviceId], + set: { platform: registration.platform, + label: registration.label, iosMajorVersion: registration.iosMajorVersion, appVersion: registration.appVersion ?? null, - pushToken: registration.pushToken ?? null, - pushToStartToken: registration.pushToStartToken ?? null, - preferencesJson: registration.preferences, - createdAt: updatedAt, - updatedAt, - }) - .onConflictDoUpdate({ - target: [relayMobileDevices.userId, relayMobileDevices.deviceId], - set: { - platform: registration.platform, - label: registration.label, - iosMajorVersion: registration.iosMajorVersion, - appVersion: registration.appVersion ?? null, - pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, - pushToStartToken: sql`coalesce( + pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, + pushToStartToken: sql`coalesce( excluded.push_to_start_token, ${relayMobileDevices.pushToStartToken} )`, - preferencesJson: registration.preferences, - updatedAt, - }, - }); - }, - Effect.mapError((cause) => new DeviceRegistrationPersistenceError({ cause })), - ), - unregister: Effect.fn("relay.devices.unregister")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.mobile.device_id": input.deviceId, - }); - yield* Effect.all( - [ - db - .delete(relayLiveActivities) - .where( - and( - eq(relayLiveActivities.userId, input.userId), - eq(relayLiveActivities.deviceId, input.deviceId), - ), + preferencesJson: registration.preferences, + updatedAt, + }, + }) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "upsert-device", + cause, + }), + ), + ); + }), + unregister: Effect.fn("relay.devices.unregister")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + }); + yield* Effect.all( + [ + db + .delete(relayLiveActivities) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), ), - db - .delete(relayMobileDevices) - .where( - and( - eq(relayMobileDevices.userId, input.userId), - eq(relayMobileDevices.deviceId, input.deviceId), - ), + ) + .pipe( + Effect.mapError( + (cause) => + new DeviceUnregistrationPersistenceError({ + userId: input.userId, + deviceId: input.deviceId, + stage: "delete-live-activity", + cause, + }), ), - ], - { concurrency: 2, discard: true }, + ), + db + .delete(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new DeviceUnregistrationPersistenceError({ + userId: input.userId, + deviceId: input.deviceId, + stage: "delete-device", + cause, + }), + ), + ), + ], + { discard: true }, + ); + }), + listForUser: Effect.fn("relay.devices.listForUser")(function* (input) { + const rows = yield* db + .select({ + deviceId: relayMobileDevices.deviceId, + label: relayMobileDevices.label, + platform: relayMobileDevices.platform, + iosMajorVersion: relayMobileDevices.iosMajorVersion, + appVersion: relayMobileDevices.appVersion, + preferences: relayMobileDevices.preferencesJson, + updatedAt: relayMobileDevices.updatedAt, + }) + .from(relayMobileDevices) + .where(eq(relayMobileDevices.userId, input.userId)) + .pipe( + Effect.mapError( + (cause) => new DeviceListPersistenceError({ userId: input.userId, cause }), + ), ); - }, - Effect.mapError((cause) => new DeviceUnregistrationPersistenceError({ cause })), - ), - listForUser: Effect.fn("relay.devices.listForUser")( - function* (input) { - const rows = yield* db - .select({ - deviceId: relayMobileDevices.deviceId, - label: relayMobileDevices.label, - platform: relayMobileDevices.platform, - iosMajorVersion: relayMobileDevices.iosMajorVersion, - appVersion: relayMobileDevices.appVersion, - preferences: relayMobileDevices.preferencesJson, - updatedAt: relayMobileDevices.updatedAt, - }) - .from(relayMobileDevices) - .where(eq(relayMobileDevices.userId, input.userId)); - return rows.map((row) => ({ - deviceId: row.deviceId, - label: row.label, - platform: row.platform, - iosMajorVersion: row.iosMajorVersion, - appVersion: row.appVersion, - notifications: { - enabled: row.preferences.notificationsEnabled, - notifyOnApproval: row.preferences.notifyOnApproval, - notifyOnInput: row.preferences.notifyOnInput, - notifyOnCompletion: row.preferences.notifyOnCompletion, - notifyOnFailure: row.preferences.notifyOnFailure, - }, - liveActivities: { - enabled: row.preferences.liveActivitiesEnabled, - }, - updatedAt: row.updatedAt, - })); - }, - Effect.mapError((cause) => new DeviceListPersistenceError({ cause })), - ), + return rows.map((row) => ({ + deviceId: row.deviceId, + label: row.label, + platform: row.platform, + iosMajorVersion: row.iosMajorVersion, + appVersion: row.appVersion, + notifications: { + enabled: row.preferences.notificationsEnabled, + notifyOnApproval: row.preferences.notifyOnApproval, + notifyOnInput: row.preferences.notifyOnInput, + notifyOnCompletion: row.preferences.notifyOnCompletion, + notifyOnFailure: row.preferences.notifyOnFailure, + }, + liveActivities: { + enabled: row.preferences.liveActivitiesEnabled, + }, + updatedAt: row.updatedAt, + })); + }), }); }); diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 19a1179b305..8f3182279bb 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -8,7 +8,7 @@ import { PgDialect } from "drizzle-orm/pg-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayLiveActivities } from "../persistence/schema.ts"; import * as LiveActivities from "./LiveActivities.ts"; @@ -88,7 +88,7 @@ describe("LiveActivities", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const liveActivities = yield* LiveActivities.LiveActivities; @@ -138,7 +138,9 @@ describe("LiveActivities", () => { }), ); }).pipe( - Effect.provide(LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb)))), + Effect.provide( + LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), ); }, ); @@ -164,7 +166,7 @@ describe("LiveActivities", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const liveActivities = yield* LiveActivities.LiveActivities; @@ -190,7 +192,97 @@ describe("LiveActivities", () => { }), ); }).pipe( - Effect.provide(LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb)))), + Effect.provide( + LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + + it.effect("preserves correlation context and causes for persistence failures", () => { + const cause = new Error("database unavailable"); + const registration: RelayLiveActivityRegistrationRequest = { + deviceId: "device-1" as RelayLiveActivityRegistrationRequest["deviceId"], + activityPushToken: + "activity-push-token" as RelayLiveActivityRegistrationRequest["activityPushToken"], + }; + const fakeDb = { + update: () => ({ + set: () => ({ where: () => Effect.fail(cause) }), + }), + insert: () => ({ + values: () => ({ onConflictDoUpdate: () => Effect.fail(cause) }), + }), + select: () => ({ + from: () => ({ + leftJoin: () => ({ where: () => Effect.fail(cause) }), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const liveActivities = yield* LiveActivities.LiveActivities; + const registrationError = yield* Effect.flip( + liveActivities.register({ userId: "user-1", registration }), + ); + const targetListError = yield* Effect.flip(liveActivities.listTargets({ userId: "user-1" })); + const deliveryErrors = yield* Effect.all( + [ + liveActivities.markDelivery({ + userId: "user-1", + deviceId: "device-1", + kind: "live_activity_update", + aggregate: null, + deliveredAt: "2026-05-25T00:00:10.000Z", + }), + liveActivities.markStartQueued({ + userId: "user-1", + deviceId: "device-1", + queuedAt: "2026-05-25T00:00:10.000Z", + }), + liveActivities.clearStartQueued({ userId: "user-1", deviceId: "device-1" }), + liveActivities.invalidateDeliveryToken({ + userId: "user-1", + deviceId: "device-1", + kind: "push_notification", + invalidatedAt: "2026-05-25T00:00:10.000Z", + }), + ].map(Effect.flip), + { concurrency: 1 }, + ); + + expect(registrationError).toMatchObject({ + userId: "user-1", + deviceId: "device-1", + cause, + message: + "Failed to persist Live Activity registration for user user-1 and device device-1.", + }); + expect(targetListError).toMatchObject({ + userId: "user-1", + cause, + message: "Failed to list Live Activity delivery targets for user user-1.", + }); + + const expectedDeliveryContext = [ + ["mark-delivery", "live_activity_update"], + ["mark-start-queued", null], + ["clear-start-queued", null], + ["invalidate-delivery-token", "push_notification"], + ] as const; + for (const [index, [operation, kind]] of expectedDeliveryContext.entries()) { + expect(deliveryErrors[index]).toMatchObject({ + operation, + userId: "user-1", + deviceId: "device-1", + kind, + cause, + message: `Failed to persist Live Activity state during ${operation} for user user-1 and device device-1.`, + }); + } + }).pipe( + Effect.provide( + LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), ); }); }); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index e7649922124..608ee0704ab 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -3,42 +3,63 @@ import type { RelayDeliveryKind, RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; -import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema } from "@t3tools/contracts/relay"; +import { + RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema, + RelayDeliveryKind as RelayDeliveryKindSchema, +} from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { cast } from "effect/Function"; +import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { and, eq, sql } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; export class LiveActivityRegistrationPersistenceError extends Schema.TaggedErrorClass()( "LiveActivityRegistrationPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + deviceId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist Live Activity registration"; + return `Failed to persist Live Activity registration for user ${this.userId} and device ${this.deviceId}.`; } } export class LiveActivityTargetListPersistenceError extends Schema.TaggedErrorClass()( "LiveActivityTargetListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list Live Activity delivery targets"; + return `Failed to list Live Activity delivery targets for user ${this.userId}.`; } } export class LiveActivityDeliveryMarkPersistenceError extends Schema.TaggedErrorClass()( "LiveActivityDeliveryMarkPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals([ + "mark-delivery", + "mark-start-queued", + "clear-start-queued", + "invalidate-delivery-token", + ]), + userId: Schema.String, + deviceId: Schema.String, + kind: Schema.NullOr(RelayDeliveryKindSchema), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist Live Activity delivery state"; + return `Failed to persist Live Activity state during ${this.operation} for user ${this.userId} and device ${this.deviceId}.`; } } @@ -64,41 +85,40 @@ export interface LiveActivityRow { export type TargetRow = DeviceRow & LiveActivityRow; -export interface LiveActivitiesShape { - readonly register: (input: { - readonly userId: string; - readonly registration: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect; - readonly listTargets: (input: { - readonly userId: string; - }) => Effect.Effect, LiveActivityTargetListPersistenceError>; - readonly markDelivery: (input: { - readonly userId: string; - readonly deviceId: string; - readonly kind: RelayDeliveryKind; - readonly aggregate: RelayAgentActivityAggregateState | null; - readonly deliveredAt: string; - }) => Effect.Effect; - readonly markStartQueued: (input: { - readonly userId: string; - readonly deviceId: string; - readonly queuedAt: string; - }) => Effect.Effect; - readonly clearStartQueued: (input: { - readonly userId: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly invalidateDeliveryToken: (input: { - readonly userId: string; - readonly deviceId: string; - readonly kind: RelayDeliveryKind; - readonly invalidatedAt: string; - }) => Effect.Effect; -} - -export class LiveActivities extends Context.Service()( - "t3code-relay/agentActivity/LiveActivities", -) {} +export class LiveActivities extends Context.Service< + LiveActivities, + { + readonly register: (input: { + readonly userId: string; + readonly registration: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly listTargets: (input: { + readonly userId: string; + }) => Effect.Effect, LiveActivityTargetListPersistenceError>; + readonly markDelivery: (input: { + readonly userId: string; + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly deliveredAt: string; + }) => Effect.Effect; + readonly markStartQueued: (input: { + readonly userId: string; + readonly deviceId: string; + readonly queuedAt: string; + }) => Effect.Effect; + readonly clearStartQueued: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly invalidateDeliveryToken: (input: { + readonly userId: string; + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly invalidatedAt: string; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/LiveActivities") {} const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); @@ -107,15 +127,15 @@ const encodeRelayAgentActivityAggregateStateJson = Schema.encodeEffect( Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), ); -const make = Effect.gen(function* () { - const db = yield* RelayDb; +export const make = Effect.gen(function* () { + const db = yield* RelayDb.RelayDb; return LiveActivities.of({ - register: Effect.fn("relay.live_activities.register")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.mobile.device_id": input.registration.deviceId, - }); + register: Effect.fn("relay.live_activities.register")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.registration.deviceId, + }); + yield* Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); const registration = input.registration; @@ -156,9 +176,17 @@ const make = Effect.gen(function* () { updatedAt, }, }); - }, - Effect.mapError((cause) => new LiveActivityRegistrationPersistenceError({ cause })), - ), + }).pipe( + Effect.mapError( + (cause) => + new LiveActivityRegistrationPersistenceError({ + userId: input.userId, + deviceId: input.registration.deviceId, + cause, + }), + ), + ); + }), listTargets: Effect.fn("relay.live_activities.list_targets")(function* (input) { return yield* db @@ -208,22 +236,28 @@ const make = Effect.gen(function* () { ), ), Effect.map((rows): ReadonlyArray => rows), - Effect.mapError((cause) => new LiveActivityTargetListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new LiveActivityTargetListPersistenceError({ + userId: input.userId, + cause, + }), + ), ); }), - markDelivery: Effect.fn("relay.live_activities.mark_delivery")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.mobile.device_id": input.deviceId, - "relay.delivery.kind": input.kind, - }); + markDelivery: Effect.fn("relay.live_activities.mark_delivery")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + "relay.delivery.kind": input.kind, + }); + yield* Effect.gen(function* () { const aggregateJson = input.aggregate === null ? null : yield* encodeRelayAgentActivityAggregateStateJson(input.aggregate).pipe( Effect.flatMap(decodeJsonString), - Effect.map(cast), + Effect.map(Function.cast), ); yield* db @@ -258,9 +292,19 @@ const make = Effect.gen(function* () { updatedAt: input.deliveredAt, }, }); - }, - Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause })), - ), + }).pipe( + Effect.mapError( + (cause) => + new LiveActivityDeliveryMarkPersistenceError({ + operation: "mark-delivery", + userId: input.userId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); + }), markStartQueued: Effect.fn("relay.live_activities.mark_start_queued")(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -288,7 +332,18 @@ const make = Effect.gen(function* () { updatedAt: input.queuedAt, }, }) - .pipe(Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new LiveActivityDeliveryMarkPersistenceError({ + operation: "mark-start-queued", + userId: input.userId, + deviceId: input.deviceId, + kind: null, + cause, + }), + ), + ); }), clearStartQueued: Effect.fn("relay.live_activities.clear_start_queued")(function* (input) { @@ -304,7 +359,18 @@ const make = Effect.gen(function* () { eq(relayLiveActivities.deviceId, input.deviceId), ), ) - .pipe(Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new LiveActivityDeliveryMarkPersistenceError({ + operation: "clear-start-queued", + userId: input.userId, + deviceId: input.deviceId, + kind: null, + cause, + }), + ), + ); }), invalidateDeliveryToken: Effect.fn("relay.live_activities.invalidate_delivery_token")( @@ -313,39 +379,58 @@ const make = Effect.gen(function* () { "relay.mobile.device_id": input.deviceId, "relay.delivery.kind": input.kind, }); - if (input.kind === "push_notification") { - yield* db - .update(relayMobileDevices) - .set({ - pushToken: null, - updatedAt: input.invalidatedAt, - }) - .where( - and( - eq(relayMobileDevices.userId, input.userId), - eq(relayMobileDevices.deviceId, input.deviceId), - ), - ); - return; - } + yield* Effect.gen(function* () { + if (input.kind === "push_notification") { + yield* db + .update(relayMobileDevices) + .set({ + pushToken: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ); + return; + } + + if (input.kind === "live_activity_start") { + yield* db + .update(relayMobileDevices) + .set({ + pushToStartToken: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ); + yield* db + .update(relayLiveActivities) + .set({ + remoteStartQueuedAt: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), + ), + ); + return; + } - if (input.kind === "live_activity_start") { - yield* db - .update(relayMobileDevices) - .set({ - pushToStartToken: null, - updatedAt: input.invalidatedAt, - }) - .where( - and( - eq(relayMobileDevices.userId, input.userId), - eq(relayMobileDevices.deviceId, input.deviceId), - ), - ); yield* db .update(relayLiveActivities) .set({ + activityPushToken: null, remoteStartQueuedAt: null, + remoteStartedAt: null, + endedAt: input.invalidatedAt, updatedAt: input.invalidatedAt, }) .where( @@ -354,26 +439,19 @@ const make = Effect.gen(function* () { eq(relayLiveActivities.deviceId, input.deviceId), ), ); - return; - } - - yield* db - .update(relayLiveActivities) - .set({ - activityPushToken: null, - remoteStartQueuedAt: null, - remoteStartedAt: null, - endedAt: input.invalidatedAt, - updatedAt: input.invalidatedAt, - }) - .where( - and( - eq(relayLiveActivities.userId, input.userId), - eq(relayLiveActivities.deviceId, input.deviceId), - ), - ); + }).pipe( + Effect.mapError( + (cause) => + new LiveActivityDeliveryMarkPersistenceError({ + operation: "invalidate-delivery-token", + userId: input.userId, + deviceId: input.deviceId, + kind: input.kind, + cause, + }), + ), + ); }, - Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause })), ), }); }); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 74cf905523b..a223e9707c4 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -7,6 +7,7 @@ import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Redacted from "effect/Redacted"; import { FetchHttpClient } from "effect/unstable/http"; @@ -38,7 +39,9 @@ const device: RelayDeviceRegistrationRequest = { }, }; -function makeDevices(overrides: Partial = {}): Devices.DevicesShape { +function makeDevices( + overrides: Partial = {}, +): Devices.Devices["Service"] { return { register: () => Effect.void, unregister: () => Effect.void, @@ -48,8 +51,8 @@ function makeDevices(overrides: Partial = {}): Devices.Dev } function makeLiveActivities( - overrides: Partial = {}, -): LiveActivities.LiveActivitiesShape { + overrides: Partial = {}, +): LiveActivities.LiveActivities["Service"] { return { register: () => Effect.void, listTargets: () => Effect.succeed([]), @@ -62,8 +65,8 @@ function makeLiveActivities( } function makeAgentActivityRows( - overrides: Partial = {}, -): AgentActivityRows.AgentActivityRowsShape { + overrides: Partial = {}, +): AgentActivityRows.AgentActivityRows["Service"] { return { upsert: () => Effect.void, remove: () => Effect.void, @@ -86,8 +89,8 @@ function makeAgentActivityRows( } function makeEnvironmentLinks( - overrides: Partial = {}, -): EnvironmentLinks.EnvironmentLinksShape { + overrides: Partial = {}, +): EnvironmentLinks.EnvironmentLinks["Service"] { return { upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), @@ -108,8 +111,8 @@ function makeEnvironmentLinks( } function makeDeliveryAttempts( - overrides: Partial = {}, -): DeliveryAttempts.DeliveryAttemptsShape { + overrides: Partial = {}, +): DeliveryAttempts.DeliveryAttempts["Service"] { return { record: () => Effect.void, claimSourceJob: () => Effect.succeed("claimed"), @@ -138,8 +141,8 @@ const config = RelayConfiguration.RelayConfiguration.of({ }); function makeRegistrationReplayLayer(input: { - readonly devices: Devices.DevicesShape; - readonly liveActivities: LiveActivities.LiveActivitiesShape; + readonly devices: Devices.Devices["Service"]; + readonly liveActivities: LiveActivities.LiveActivities["Service"]; readonly queuedJobs: Array; }) { return MobileRegistrations.layer.pipe( @@ -153,7 +156,7 @@ function makeRegistrationReplayLayer(input: { Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), Layer.succeed(LiveActivities.LiveActivities, input.liveActivities), Layer.succeed(DeliveryAttempts.DeliveryAttempts, makeDeliveryAttempts()), - Layer.succeed(RelayConfiguration.RelayConfiguration, config), + RelayConfiguration.layer(config), Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { send: (body) => Effect.sync(() => { @@ -167,8 +170,8 @@ function makeRegistrationReplayLayer(input: { } function makeAgentActivityPublisher( - overrides: Partial = {}, -): AgentActivityPublisher.AgentActivityPublisherShape { + overrides: Partial = {}, +): AgentActivityPublisher.AgentActivityPublisher["Service"] { return { publish: () => Effect.succeed({ ok: true, deliveries: [] }), replayForLiveActivityRegistration: () => Effect.succeed(null), @@ -178,10 +181,10 @@ function makeAgentActivityPublisher( describe("MobileRegistrations", () => { it.effect("registers devices through the device persistence service", () => { - let registered: Parameters[0] | null = null; + let registered: Parameters[0] | null = null; let replayed: | Parameters< - AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] + AgentActivityPublisher.AgentActivityPublisher["Service"]["replayForLiveActivityRegistration"] >[0] | null = null; @@ -230,6 +233,11 @@ describe("MobileRegistrations", () => { }); it.effect("keeps device registration successful when activity replay fails", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + return Effect.gen(function* () { const result = yield* Effect.gen(function* () { const registrations = yield* MobileRegistrations.MobileRegistrations; @@ -247,7 +255,8 @@ describe("MobileRegistrations", () => { replayForLiveActivityRegistration: () => Effect.fail( new AgentActivityRows.AgentActivityRowListPersistenceError({ - cause: "replay failed", + userId: "dev:julius", + cause: "sensitive device replay detail", }), ), }), @@ -259,11 +268,15 @@ describe("MobileRegistrations", () => { ); expect(result).toEqual({ ok: true }); - }); + expect(messages).toContainEqual([ + "device registration activity replay failed", + { errorTag: "AgentActivityRowListPersistenceError" }, + ]); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); it.effect("unregisters the current user's device", () => { - let unregistered: Parameters[0] | null = null; + let unregistered: Parameters[0] | null = null; return Effect.gen(function* () { const result = yield* Effect.gen(function* () { @@ -310,10 +323,11 @@ describe("MobileRegistrations", () => { deviceId: "device-1" as const, activityPushToken: "activity-token" as const, }; - let registered: Parameters[0] | null = null; + let registered: Parameters[0] | null = + null; let replayed: | Parameters< - AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] + AgentActivityPublisher.AgentActivityPublisher["Service"]["replayForLiveActivityRegistration"] >[0] | null = null; @@ -372,9 +386,9 @@ describe("MobileRegistrations", () => { () => { const queuedJobs: Array = []; const queuedStarts: Array< - Parameters[0] + Parameters[0] > = []; - const registeredDevices: Array[0]> = []; + const registeredDevices: Array[0]> = []; const devices = makeDevices({ register: (input) => Effect.sync(() => { diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index d9c013232a3..0df0379cded 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -15,27 +15,25 @@ export type MobileRegistrationError = | Devices.DeviceUnregistrationPersistenceError | LiveActivities.LiveActivityRegistrationPersistenceError; -export interface MobileRegistrationsShape { - readonly registerDevice: (input: { - readonly userId: string; - readonly payload: RelayDeviceRegistrationRequest; - }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; - readonly registerLiveActivity: (input: { - readonly userId: string; - readonly payload: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; - readonly unregisterDevice: (input: { - readonly userId: string; - readonly deviceId: string; - }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; -} - export class MobileRegistrations extends Context.Service< MobileRegistrations, - MobileRegistrationsShape + { + readonly registerDevice: (input: { + readonly userId: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + readonly registerLiveActivity: (input: { + readonly userId: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + readonly unregisterDevice: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + } >()("t3code-relay/agentActivity/MobileRegistrations") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const devices = yield* Devices.Devices; const liveActivities = yield* LiveActivities.LiveActivities; const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; @@ -53,8 +51,10 @@ const make = Effect.gen(function* () { deviceId: input.payload.deviceId, }) .pipe( - Effect.tapError((cause) => - Effect.logWarning("device registration activity replay failed", { cause }), + Effect.tapError((error) => + Effect.logWarning("device registration activity replay failed", { + errorTag: error._tag, + }), ), Effect.ignore, ); @@ -72,8 +72,10 @@ const make = Effect.gen(function* () { deviceId: input.payload.deviceId, }) .pipe( - Effect.tapError((cause) => - Effect.logWarning("live activity registration replay failed", { cause }), + Effect.tapError((error) => + Effect.logWarning("live activity registration replay failed", { + errorTag: error._tag, + }), ), Effect.ignore, ); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts index 428dc3a82b6..a0a45b9ed72 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts @@ -69,7 +69,12 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobSignatureInvalid", + jobId: "job-1", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + message: "Invalid signature for APNs delivery job job-1.", }); }); @@ -93,8 +98,12 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", - message: "Live Activity start/update jobs require an aggregate.", + _tag: "ApnsDeliveryJobLiveActivityAggregateMissing", + jobId: "job-start-invalid", + kind: "live_activity_start", + userId: "user-1", + deviceId: "device-1", + message: "APNs live activity start job job-start-invalid requires an aggregate.", }); }); @@ -119,8 +128,11 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", - message: "Push notification jobs must not carry aggregate state.", + _tag: "ApnsDeliveryJobPushNotificationAggregateUnexpected", + jobId: "job-push-invalid", + userId: "user-1", + deviceId: "device-1", + message: "APNs push notification job job-push-invalid must not carry aggregate state.", }); }); @@ -194,8 +206,13 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", - message: "Invalid APNs delivery job creation time.", + _tag: "ApnsDeliveryJobCreatedAtInvalid", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "not-a-date", + message: "APNs delivery job job-window has invalid creation time not-a-date.", }); expect( verifySignedApnsDeliveryJob({ @@ -204,8 +221,15 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", - message: "Invalid APNs delivery job time window.", + _tag: "ApnsDeliveryJobTimeWindowInvalid", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-24T23:59:59.000Z", + message: + "APNs delivery job job-window has invalid time window 2026-05-25T00:00:00.000Z to 2026-05-24T23:59:59.000Z.", }); expect( verifySignedApnsDeliveryJob({ @@ -214,8 +238,15 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", - message: "APNs delivery job time window is too long.", + _tag: "ApnsDeliveryJobTimeWindowTooLong", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:10:01.000Z", + message: + "APNs delivery job job-window time window 2026-05-25T00:00:00.000Z to 2026-05-25T00:10:01.000Z is too long.", }); }); }); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index d509baa9168..2af61085eab 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -10,12 +10,27 @@ import * as Schema from "effect/Schema"; const MAX_JOB_AGE_MS = 10 * 60 * 1_000; export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256"; -const ApnsDeliveryKind = Schema.Literals([ +const ApnsDeliveryKindSchema = Schema.Literals([ "live_activity_start", "live_activity_update", "live_activity_end", "push_notification", ]); +const LiveActivityStartOrUpdateKindSchema = Schema.Literals([ + "live_activity_start", + "live_activity_update", +]); +const LiveActivityKindSchema = Schema.Literals([ + "live_activity_start", + "live_activity_update", + "live_activity_end", +]); + +const ApnsDeliveryJobContext = { + jobId: Schema.String, + userId: Schema.String, + deviceId: Schema.String, +}; export const ApnsNotificationPayload = Schema.Struct({ title: Schema.String, @@ -29,7 +44,7 @@ export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; export const ApnsDeliveryJobPayload = Schema.Struct({ version: Schema.Literal(1), jobId: Schema.String, - kind: ApnsDeliveryKind, + kind: ApnsDeliveryKindSchema, target: Schema.Struct({ userId: Schema.String, deviceId: Schema.String, @@ -49,25 +64,160 @@ export const SignedApnsDeliveryJob = Schema.Struct({ }); export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; -export class ApnsDeliveryJobInvalid extends Schema.TaggedErrorClass()( - "ApnsDeliveryJobInvalid", +export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobQueuePayloadInvalid", + { + receivedType: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid APNs delivery queue job with ${this.receivedType} payload.`; + } +} + +export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobLiveActivityAggregateMissing", + { + ...ApnsDeliveryJobContext, + kind: LiveActivityStartOrUpdateKindSchema, + }, +) { + override get message(): string { + return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} requires an aggregate.`; + } +} + +export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobLiveActivityNotificationUnexpected", { - message: Schema.String, + ...ApnsDeliveryJobContext, + kind: LiveActivityKindSchema, }, -) {} +) { + override get message(): string { + return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} must not carry a push notification payload.`; + } +} + +export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobPushNotificationMissing", + ApnsDeliveryJobContext, +) { + override get message(): string { + return `APNs push notification job ${this.jobId} requires a notification payload.`; + } +} + +export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobPushNotificationAggregateUnexpected", + ApnsDeliveryJobContext, +) { + override get message(): string { + return `APNs push notification job ${this.jobId} must not carry aggregate state.`; + } +} + +export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobCreatedAtInvalid", + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + }, +) { + override get message(): string { + return `APNs delivery job ${this.jobId} has invalid creation time ${this.createdAt}.`; + } +} + +export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobExpiresAtInvalid", + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + expiresAt: Schema.String, + }, +) { + override get message(): string { + return `APNs delivery job ${this.jobId} has invalid expiry ${this.expiresAt}.`; + } +} + +export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobTimeWindowInvalid", + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + expiresAt: Schema.String, + }, +) { + override get message(): string { + return `APNs delivery job ${this.jobId} has invalid time window ${this.createdAt} to ${this.expiresAt}.`; + } +} + +export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobTimeWindowTooLong", + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + expiresAt: Schema.String, + }, +) { + override get message(): string { + return `APNs delivery job ${this.jobId} time window ${this.createdAt} to ${this.expiresAt} is too long.`; + } +} + +export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobSignatureInvalid", + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + }, +) { + override get message(): string { + return `Invalid signature for APNs delivery job ${this.jobId}.`; + } +} + +export const ApnsDeliveryJobInvalid = Schema.Union([ + ApnsDeliveryJobQueuePayloadInvalid, + ApnsDeliveryJobLiveActivityAggregateMissing, + ApnsDeliveryJobLiveActivityNotificationUnexpected, + ApnsDeliveryJobPushNotificationMissing, + ApnsDeliveryJobPushNotificationAggregateUnexpected, + ApnsDeliveryJobCreatedAtInvalid, + ApnsDeliveryJobExpiresAtInvalid, + ApnsDeliveryJobTimeWindowInvalid, + ApnsDeliveryJobTimeWindowTooLong, + ApnsDeliveryJobSignatureInvalid, +]); +export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type; export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass()( "ApnsDeliveryJobExpired", { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, expiresAt: Schema.String, }, ) { override get message(): string { - return `APNs delivery job expired at ${this.expiresAt}`; + return `APNs delivery job ${this.jobId} expired at ${this.expiresAt}.`; } } -export type ApnsDeliveryJobVerificationError = ApnsDeliveryJobInvalid | ApnsDeliveryJobExpired; +export const ApnsDeliveryJobVerificationError = Schema.Union([ + ApnsDeliveryJobInvalid, + ApnsDeliveryJobExpired, +]); +export type ApnsDeliveryJobVerificationError = typeof ApnsDeliveryJobVerificationError.Type; + +export const isApnsDeliveryJobVerificationError = Schema.is(ApnsDeliveryJobVerificationError); export function makeApnsDeliveryJobPayload(input: { readonly kind: RelayDeliveryKind; @@ -105,32 +255,45 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI case "live_activity_start": case "live_activity_update": if (payload.aggregate === null) { - return new ApnsDeliveryJobInvalid({ - message: "Live Activity start/update jobs require an aggregate.", + return new ApnsDeliveryJobLiveActivityAggregateMissing({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, }); } if (payload.notification !== null) { - return new ApnsDeliveryJobInvalid({ - message: "Live Activity jobs must not carry push notification payloads.", + return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, }); } return null; case "live_activity_end": if (payload.notification !== null) { - return new ApnsDeliveryJobInvalid({ - message: "Live Activity jobs must not carry push notification payloads.", + return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, }); } return null; case "push_notification": if (payload.notification === null) { - return new ApnsDeliveryJobInvalid({ - message: "Push notification jobs require a notification payload.", + return new ApnsDeliveryJobPushNotificationMissing({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, }); } if (payload.aggregate !== null) { - return new ApnsDeliveryJobInvalid({ - message: "Push notification jobs must not carry aggregate state.", + return new ApnsDeliveryJobPushNotificationAggregateUnexpected({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, }); } return null; @@ -171,35 +334,73 @@ export function verifySignedApnsDeliveryJob(input: { readonly job: SignedApnsDeliveryJob; readonly nowMs: number; }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { - const invalidPayload = validatePayloadShape(input.job.payload); + const payload = input.job.payload; + const invalidPayload = validatePayloadShape(payload); if (invalidPayload !== null) { return invalidPayload; } - const createdAt = DateTime.make(input.job.payload.createdAt); + const createdAt = DateTime.make(payload.createdAt); if (Option.isNone(createdAt)) { - return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job creation time." }); + return new ApnsDeliveryJobCreatedAtInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + }); } - const expiresAt = DateTime.make(input.job.payload.expiresAt); + const expiresAt = DateTime.make(payload.expiresAt); if (Option.isNone(expiresAt)) { - return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job expiry." }); + return new ApnsDeliveryJobExpiresAtInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + expiresAt: payload.expiresAt, + }); } const createdAtMs = createdAt.value.epochMilliseconds; const expiresAtMs = expiresAt.value.epochMilliseconds; if (expiresAtMs <= createdAtMs) { - return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job time window." }); + return new ApnsDeliveryJobTimeWindowInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + expiresAt: payload.expiresAt, + }); } if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { - return new ApnsDeliveryJobInvalid({ message: "APNs delivery job time window is too long." }); + return new ApnsDeliveryJobTimeWindowTooLong({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + expiresAt: payload.expiresAt, + }); } if (expiresAtMs <= input.nowMs) { - return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); + return new ApnsDeliveryJobExpired({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + expiresAt: payload.expiresAt, + }); } const expected = signatureForPayload({ secret: input.secret, - payload: input.job.payload, + payload, }); if (!timingSafeEqualBase64Url(input.job.signature, expected)) { - return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job signature." }); + return new ApnsDeliveryJobSignatureInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } - return input.job.payload; + return payload; } diff --git a/infra/relay/src/auth/DpopProofs.test.ts b/infra/relay/src/auth/DpopProofs.test.ts index 9fae6298c9c..fba64586e28 100644 --- a/infra/relay/src/auth/DpopProofs.test.ts +++ b/infra/relay/src/auth/DpopProofs.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDpopProofs } from "../persistence/schema.ts"; import * as DpopProofs from "./DpopProofs.ts"; @@ -41,7 +41,7 @@ describe("DpopProofReplay", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const replay = yield* DpopProofs.DpopProofReplay; @@ -67,7 +67,9 @@ describe("DpopProofReplay", () => { expiresAt: "2026-05-25T12:00:00.000Z", }, ]); - }).pipe(Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); it.effect("prunes expired proof rows from the maintenance path", () => { @@ -84,12 +86,40 @@ describe("DpopProofReplay", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const replay = yield* DpopProofs.DpopProofReplay; yield* replay.pruneExpired; expect(calls).toEqual(["delete", "delete.where"]); - }).pipe(Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); + + it.effect("retains the prune cutoff and database failure", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + delete: (table: unknown) => { + expect(table).toBe(relayDpopProofs); + return { + where: () => Effect.fail(cause), + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const error = yield* Effect.flip(replay.pruneExpired); + + expect(error).toMatchObject({ + _tag: "DpopProofReplayPersistenceError", + operation: "prune-expired", + }); + expect(Date.parse(error.expiresBefore ?? "")).not.toBeNaN(); + expect(error.cause).toBe(cause); + }).pipe( + Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); }); diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts index cd59a984fa1..fa784eb639b 100644 --- a/infra/relay/src/auth/DpopProofs.ts +++ b/infra/relay/src/auth/DpopProofs.ts @@ -7,48 +7,50 @@ import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; import { lt } from "drizzle-orm"; import { verifyDpopProof } from "@t3tools/shared/dpop"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDpopProofs } from "../persistence/schema.ts"; export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass()( "DpopProofReplayPersistenceError", { + operation: Schema.Literals(["consume", "prune-expired"]), + thumbprint: Schema.optionalKey(Schema.String), + jti: Schema.optionalKey(Schema.String), + iat: Schema.optionalKey(Schema.Number), + expiresBefore: Schema.optionalKey(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to persist DPoP proof replay state"; + return `Failed to persist DPoP proof replay state during '${this.operation}'`; } } -export interface DpopProofReplayShape { - readonly verifyAndConsume: (input: { - readonly proof: string | undefined; - readonly method: string; - readonly url: string; - readonly expectedThumbprint?: string; - readonly expectedAccessToken?: string; - readonly now: DateTime.DateTime; - }) => Effect.Effect; - - readonly consume: (input: { - readonly thumbprint: string; - readonly jti: string; - readonly iat: number; - readonly expiresAt: DateTime.DateTime; - }) => Effect.Effect; - - readonly pruneExpired: Effect.Effect; -} - -export class DpopProofReplay extends Context.Service()( - "t3code-relay/auth/DpopProofs/DpopProofReplay", -) {} +export class DpopProofReplay extends Context.Service< + DpopProofReplay, + { + readonly verifyAndConsume: (input: { + readonly proof: string | undefined; + readonly method: string; + readonly url: string; + readonly expectedThumbprint?: string; + readonly expectedAccessToken?: string; + readonly now: DateTime.DateTime; + }) => Effect.Effect; + readonly consume: (input: { + readonly thumbprint: string; + readonly jti: string; + readonly iat: number; + readonly expiresAt: DateTime.DateTime; + }) => Effect.Effect; + readonly pruneExpired: Effect.Effect; + } +>()("t3code-relay/auth/DpopProofs/DpopProofReplay") {} const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; - const consume: DpopProofReplayShape["consume"] = Effect.fn("relay.dpop_proofs.consume")( + const consume: DpopProofReplay["Service"]["consume"] = Effect.fn("relay.dpop_proofs.consume")( function* (input) { const createdAt = DateTime.formatIso(yield* DateTime.now); const inserted = yield* db @@ -61,13 +63,24 @@ const make = Effect.gen(function* () { createdAt, }) .onConflictDoNothing() - .returning({ jti: relayDpopProofs.jti }); + .returning({ jti: relayDpopProofs.jti }) + .pipe( + Effect.mapError( + (cause) => + new DpopProofReplayPersistenceError({ + operation: "consume", + thumbprint: input.thumbprint, + jti: input.jti, + iat: input.iat, + cause, + }), + ), + ); return inserted.length > 0; }, - Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), ); - const verifyAndConsume: DpopProofReplayShape["verifyAndConsume"] = Effect.fn( + const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn( "relay.dpop_proofs.verify_and_consume", )(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -114,14 +127,23 @@ const make = Effect.gen(function* () { return result.thumbprint; }); - const pruneExpired: DpopProofReplayShape["pruneExpired"] = Effect.gen(function* () { + const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now }); - yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now)); - }).pipe( - Effect.withSpan("relay.dpop_proofs.prune_expired"), - Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), - ); + yield* db + .delete(relayDpopProofs) + .where(lt(relayDpopProofs.expiresAt, now)) + .pipe( + Effect.mapError( + (cause) => + new DpopProofReplayPersistenceError({ + operation: "prune-expired", + expiresBefore: now, + cause, + }), + ), + ); + }).pipe(Effect.withSpan("relay.dpop_proofs.prune_expired")); return DpopProofReplay.of({ verifyAndConsume, diff --git a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts index d09ee76e42c..7663e874879 100644 --- a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts +++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts @@ -10,7 +10,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDpopProofs } from "../persistence/schema.ts"; import * as DpopProofs from "./DpopProofs.ts"; @@ -78,8 +78,8 @@ function layer( }), }; }, - } as unknown as RelayDatabase; - return DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))); + } as unknown as RelayDb.RelayDb["Service"]; + return DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))); } function consumeEachProofOnce() { @@ -163,7 +163,7 @@ describe("DpopProofReplay.verifyAndConsume", () => { iat: Math.floor(now.epochMilliseconds / 1_000), jti: "proof-persistence-failure", }); - const cause = "database unavailable"; + const cause = { _tag: "DatabaseUnavailable" } as const; return Effect.gen(function* () { const replay = yield* DpopProofs.DpopProofReplay; @@ -177,8 +177,16 @@ describe("DpopProofReplay.verifyAndConsume", () => { }), ); - expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause })); - }).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause })))); + expect(error).toMatchObject({ + _tag: "DpopProofReplayPersistenceError", + operation: "consume", + thumbprint: proof.thumbprint, + jti: "proof-persistence-failure", + iat: Math.floor(now.epochMilliseconds / 1_000), + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("proof"); + }).pipe(Effect.provide(layer(() => Effect.fail(cause)))); }); it.effect("accepts proofs bound to the access token hash", () => { diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts index 171981834c8..c4a65771e58 100644 --- a/infra/relay/src/auth/RelayTokens.test.ts +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -33,9 +33,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ managedEndpointNamespace: undefined, }); -const layer = RelayTokens.layer.pipe( - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), -); +const layer = RelayTokens.layer.pipe(Layer.provide(RelayConfiguration.layer(config))); describe("RelayTokens", () => { it.effect("issues a user-bound environment link challenge", () => @@ -88,20 +86,22 @@ describe("RelayTokens", () => { proofKeyThumbprint: "proof-key-thumbprint", jti: "access-token-1", issuedAtEpochSeconds: 100, - expiresAtEpochSeconds: 200, + expiresAtEpochSeconds: 1_900, clientId: "t3-mobile", scopes: ["environment:connect", "environment:status", "mobile:registration"], }); expect( - yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 }), + yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 700 }), ).toMatchObject({ sub: "user_123", cnf: { jkt: "proof-key-thumbprint" }, client_id: "t3-mobile", scope: ["environment:connect", "environment:status", "mobile:registration"], }); - expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 261 })).toBeNull(); + expect( + yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 1_961 }), + ).toBeNull(); }).pipe(Effect.provide(layer)), ); diff --git a/infra/relay/src/auth/RelayTokens.ts b/infra/relay/src/auth/RelayTokens.ts index f7f02c49f8c..bf48980907a 100644 --- a/infra/relay/src/auth/RelayTokens.ts +++ b/infra/relay/src/auth/RelayTokens.ts @@ -11,9 +11,9 @@ import { import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { normalizeRelayIssuer, + RelayJwtError, signRelayJwt, verifyRelayJwt, - type RelayJwtError, } from "@t3tools/shared/relayJwt"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -26,6 +26,7 @@ import * as RelayConfiguration from "../Config.ts"; const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt"; const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt"; const LINK_CHALLENGE_KIND = "environment_link_challenge"; +export const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; const LinkChallengeClaims = Schema.Struct({ kind: Schema.Literal(LINK_CHALLENGE_KIND), @@ -81,45 +82,44 @@ function resolveDpopAccessTokenScopes(input: { }); } -export interface RelayTokensShape { - readonly resolveDpopAccessTokenScopes: typeof resolveDpopAccessTokenScopes; - readonly issueLinkChallenge: (input: { - readonly userId: string; - readonly request: RelayEnvironmentLinkChallengeRequest; - readonly jti: string; - readonly issuedAtEpochSeconds: number; - readonly expiresAtEpochSeconds: number; - }) => Effect.Effect; - readonly verifyLinkChallenge: (input: { - readonly token: string; - readonly userId: string; - readonly request: RelayEnvironmentLinkChallengeRequest; - readonly nowEpochSeconds: number; - }) => Effect.Effect; - readonly issueDpopAccessToken: (input: { - readonly userId: string; - readonly proofKeyThumbprint: string; - readonly jti: string; - readonly issuedAtEpochSeconds: number; - readonly expiresAtEpochSeconds: number; - readonly clientId: RelayPublicClientId; - readonly scopes: ReadonlyArray; - }) => Effect.Effect; - readonly verifyDpopAccessToken: (input: { - readonly token: string; - readonly nowEpochSeconds: number; - }) => Effect.Effect; -} - -export class RelayTokens extends Context.Service()( - "t3code-relay/auth/RelayTokens", -) {} +export class RelayTokens extends Context.Service< + RelayTokens, + { + readonly resolveDpopAccessTokenScopes: typeof resolveDpopAccessTokenScopes; + readonly issueLinkChallenge: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkChallengeRequest; + readonly jti: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + }) => Effect.Effect; + readonly verifyLinkChallenge: (input: { + readonly token: string; + readonly userId: string; + readonly request: RelayEnvironmentLinkChallengeRequest; + readonly nowEpochSeconds: number; + }) => Effect.Effect; + readonly issueDpopAccessToken: (input: { + readonly userId: string; + readonly proofKeyThumbprint: string; + readonly jti: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + readonly clientId: RelayPublicClientId; + readonly scopes: ReadonlyArray; + }) => Effect.Effect; + readonly verifyDpopAccessToken: (input: { + readonly token: string; + readonly nowEpochSeconds: number; + }) => Effect.Effect; + } +>()("t3code-relay/auth/RelayTokens") {} const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; const issuer = normalizeRelayIssuer(config.relayIssuer); - const issueLinkChallenge: RelayTokensShape["issueLinkChallenge"] = Effect.fn( + const issueLinkChallenge: RelayTokens["Service"]["issueLinkChallenge"] = Effect.fn( "relay.tokens.issue_link_challenge", )(function* (input) { return yield* signRelayJwt({ @@ -138,7 +138,7 @@ const make = Effect.gen(function* () { }); }); - const verifyLinkChallenge: RelayTokensShape["verifyLinkChallenge"] = Effect.fn( + const verifyLinkChallenge: RelayTokens["Service"]["verifyLinkChallenge"] = Effect.fn( "relay.tokens.verify_link_challenge", )((input) => verifyRelayJwt({ @@ -165,7 +165,7 @@ const make = Effect.gen(function* () { ), ); - const issueDpopAccessToken: RelayTokensShape["issueDpopAccessToken"] = Effect.fn( + const issueDpopAccessToken: RelayTokens["Service"]["issueDpopAccessToken"] = Effect.fn( "relay.tokens.issue_dpop_access_token", )(function* (input) { return yield* signRelayJwt({ @@ -185,7 +185,7 @@ const make = Effect.gen(function* () { }); }); - const verifyDpopAccessToken: RelayTokensShape["verifyDpopAccessToken"] = Effect.fn( + const verifyDpopAccessToken: RelayTokens["Service"]["verifyDpopAccessToken"] = Effect.fn( "relay.tokens.verify_dpop_access_token", )((input) => verifyRelayJwt({ @@ -195,7 +195,14 @@ const make = Effect.gen(function* () { issuer, audience: issuer, nowEpochSeconds: input.nowEpochSeconds, + maxTokenAge: RELAY_DPOP_ACCESS_TOKEN_TTL, }).pipe( + Effect.tapError((error) => + Effect.annotateCurrentSpan( + "relay.tokens.verification_failure", + RelayJwtError.diagnosticCode(error), + ), + ), Effect.flatMap(decodeDpopAccessTokenClaims), Effect.map((claims): RelayDpopAccessTokenClaims | null => { const scopes = resolveDpopAccessTokenScopes({ diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts index e812fc7b686..99db09439c3 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -10,11 +10,12 @@ import * as Effect from "effect/Effect"; import { relayDatabaseMode } from "./dbConfig.ts"; -export interface RelayDatabase extends EffectPgDatabase { - readonly $client: PgClient; -} - -export class RelayDb extends Context.Service()("t3code-relay/db/RelayDb") {} +export class RelayDb extends Context.Service< + RelayDb, + EffectPgDatabase & { + readonly $client: PgClient; + } +>()("t3code-relay/db/RelayDb") {} export const PlanetscaleDatabase = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index d7940b80318..44c7627a4da 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { managedEndpointDigestInput, @@ -7,11 +8,14 @@ import { isManagedEndpointHostname, managedEndpointTunnelName, relayOwnsManagedEndpointZone, + RelayPublicDomainLabelTooLongError, relayPublicDomainForStage, relayResourceNameForStage, relayStageSlug, } from "./deploymentConfig.ts"; +const isRelayPublicDomainLabelTooLongError = Schema.is(RelayPublicDomainLabelTooLongError); + describe("relayStageSlug", () => { it("matches Alchemy physical-name sanitization for default developer stages", () => { expect(relayStageSlug("dev_julius")).toBe("dev-julius"); @@ -28,6 +32,29 @@ describe("relayPublicDomainForStage", () => { "relay-dev-julius.example.com", ); }); + + it("reports the stage and derived DNS label when the label is too long", () => { + const stage = `dev_${"x".repeat(60)}`; + let error: unknown; + + try { + relayPublicDomainForStage(stage, "example.com"); + } catch (cause) { + error = cause; + } + + if (!isRelayPublicDomainLabelTooLongError(error)) { + throw error; + } + expect(error).toMatchObject({ + stage, + label: `relay-dev-${"x".repeat(60)}`, + maxLength: 63, + }); + expect(error.message).toBe( + `Relay stage '${stage}' produces custom domain label 'relay-dev-${"x".repeat(60)}' (70 characters), exceeding the DNS label limit of 63.`, + ); + }); }); describe("relayOwnsManagedEndpointZone", () => { diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index fbb13054822..fe9d37b2998 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -1,10 +1,24 @@ import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import * as Schema from "effect/Schema"; const DNS_LABEL_MAX_LENGTH = 63; const MANAGED_ENDPOINT_HASH_LENGTH = 16; const MANAGED_ENDPOINT_TUNNEL_PREFIX = "t3coderelay-managedendpoint"; export const MANAGED_ENDPOINT_ZONE_OWNER_STAGE = "prod"; +export class RelayPublicDomainLabelTooLongError extends Schema.TaggedErrorClass()( + "RelayPublicDomainLabelTooLongError", + { + stage: Schema.String, + label: Schema.String, + maxLength: Schema.Number, + }, +) { + override get message(): string { + return `Relay stage '${this.stage}' produces custom domain label '${this.label}' (${this.label.length} characters), exceeding the DNS label limit of ${this.maxLength}.`; + } +} + function normalizeZoneName(zoneName: string): string { return zoneName .trim() @@ -62,7 +76,11 @@ export function relayPublicDomainForStage(stage: string, zoneName: string): stri const stageSlug = relayStageSlug(stage); const relayLabel = stage === "prod" ? "relay" : `relay-${stageSlug}`; if (relayLabel.length > DNS_LABEL_MAX_LENGTH) { - throw new Error(`Relay stage is too long for a custom domain: ${stage}`); + throw new RelayPublicDomainLabelTooLongError({ + stage, + label: relayLabel, + maxLength: DNS_LABEL_MAX_LENGTH, + }); } return `${relayLabel}.${normalizeZoneName(zoneName)}`; } diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index c6bec6d4bae..63f12379870 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -161,8 +161,8 @@ function connectorTestLayer( request: HttpClientRequest.HttpClientRequest, ) => Effect.Effect, options?: { - readonly links?: EnvironmentLinks.EnvironmentLinksShape; - readonly allocations?: ManagedEndpointAllocations.ManagedEndpointAllocationsShape; + readonly links?: EnvironmentLinks.EnvironmentLinks["Service"]; + readonly allocations?: ManagedEndpointAllocations.ManagedEndpointAllocations["Service"]; }, ) { return EnvironmentConnector.layer.pipe( @@ -174,7 +174,7 @@ function connectorTestLayer( options?.allocations ?? makeAllocations(), ), ), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), + Layer.provide(RelayConfiguration.layer(settings)), Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), ); } @@ -189,7 +189,7 @@ function makeAllocations( dnsRecordId: "dns-record-id", readyAt: "2026-05-25T00:00:00.000Z", }, -): ManagedEndpointAllocations.ManagedEndpointAllocationsShape { +): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] { return { get: () => Effect.succeed(allocation), reserve: () => Effect.die("unused"), @@ -202,7 +202,7 @@ function makeAllocations( function makeLinks( overrides: Partial = {}, -): EnvironmentLinks.EnvironmentLinksShape { +): EnvironmentLinks.EnvironmentLinks["Service"] { return { upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed([]), @@ -535,6 +535,7 @@ describe("EnvironmentConnector", () => { environmentId: "env-connector-test", status: "offline", error: "Managed endpoint health request failed: Environment is unavailable.", + traceId: expect.any(String), }); }).pipe(Effect.provide(connectorTestLayer(execute))); }); diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index c62d1166962..db662aee94d 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -5,7 +5,7 @@ import { EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, } from "@t3tools/contracts"; -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { RelayCloudEnvironmentHealthProofPayload, RelayEnvironmentHealthResponse, @@ -35,7 +35,9 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as EnvironmentLinks from "./EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; @@ -139,22 +141,20 @@ export type EnvironmentConnectorError = export const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 10_000; const ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS = 60 * 1_000; -export interface EnvironmentConnectorShape { - readonly connect: (input: { - readonly userId: string; - readonly environmentId: string; - readonly clientProofKeyThumbprint: string; - readonly deviceId?: string; - }) => Effect.Effect; - readonly status: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; -} - export class EnvironmentConnector extends Context.Service< EnvironmentConnector, - EnvironmentConnectorShape + { + readonly connect: (input: { + readonly userId: string; + readonly environmentId: string; + readonly clientProofKeyThumbprint: string; + readonly deviceId?: string; + }) => Effect.Effect; + readonly status: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + } >()("t3code-relay/environments/EnvironmentConnector") {} const decodeMintResponseProof = Schema.decodeUnknownEffect( @@ -179,6 +179,24 @@ function environmentHealthRequestFailureMessage(cause: unknown): string { : "Managed endpoint health request failed."; } +function environmentHealthRequestFailureReason(cause: unknown): string { + if (isEnvironmentHealthError(cause)) { + return cause._tag; + } + if (HttpClientError.isHttpClientError(cause)) { + return cause.reason._tag; + } + if (Schema.isSchemaError(cause)) { + return "SchemaError"; + } + return cause instanceof Error && cause.name ? cause.name : "Unknown"; +} + +const currentTraceId = Effect.currentSpan.pipe( + Effect.map((span) => span.traceId), + Effect.orElseSucceed(() => "unavailable"), +); + const withoutRedirects = (effect: Effect.Effect) => effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); @@ -457,6 +475,7 @@ const make = Effect.gen(function* () { ), ); const checkedAt = DateTime.formatIso(now); + const traceId = yield* currentTraceId; const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); const responseOption = yield* environmentClient.connect.health({ payload: { proof } }).pipe( withoutRedirects, @@ -467,21 +486,44 @@ const make = Effect.gen(function* () { Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), ); if (Option.isNone(responseOption)) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_health.outcome": "timeout", + "relay.environment_health.trace_id": traceId, + }); + yield* Effect.logWarning("Managed endpoint health request timed out", { + environmentId: link.environmentId, + endpoint: endpoint.httpBaseUrl, + traceId, + }); return { environmentId: link.environmentId, endpoint, status: "offline" as const, checkedAt, error: "Managed endpoint health request timed out.", + traceId, }; } if (responseOption.value._tag === "Failure") { + const failureReason = environmentHealthRequestFailureReason(responseOption.value.cause); + yield* Effect.annotateCurrentSpan({ + "relay.environment_health.outcome": "failure", + "relay.environment_health.failure_reason": failureReason, + "relay.environment_health.trace_id": traceId, + }); + yield* Effect.logWarning("Managed endpoint health request failed", { + environmentId: link.environmentId, + endpoint: endpoint.httpBaseUrl, + failureReason, + traceId, + }); return { environmentId: link.environmentId, endpoint, status: "offline" as const, checkedAt, error: environmentHealthRequestFailureMessage(responseOption.value.cause), + traceId, }; } const decoded = responseOption.value.response; diff --git a/infra/relay/src/environments/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts index 9282564e985..4e12dabe831 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.test.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -4,11 +4,93 @@ import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayEnvironmentCredentials } from "../persistence/schema.ts"; import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; describe("EnvironmentCredentials", () => { + it.effect("reports the credential creation persistence stage and preserves its cause", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + values: () => Effect.void, + }; + }, + update: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + set: () => ({ + where: () => Effect.fail(cause), + }), + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const error = yield* Effect.flip( + credentials.create({ + environmentId: "env_test", + environmentPublicKey: "sensitive-public-key-material", + }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentCredentialCreatePersistenceError", + stage: "revoke-previous-credentials", + environmentId: "env_test", + }); + expect(error.credentialId).toMatch(/^[0-9a-f]{64}$/); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("environmentPublicKey"); + }).pipe( + Effect.provide( + EnvironmentCredentials.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("does not retain credential tokens when lookup persistence fails", () => { + const cause = new Error("database unavailable"); + const token = "t3env_sensitive-credential-token"; + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const error = yield* Effect.flip(credentials.authenticate(token)); + + expect(error).toMatchObject({ + _tag: "EnvironmentCredentialAuthenticatePersistenceError", + stage: "lookup-credential", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("token"); + }).pipe( + Effect.provide( + EnvironmentCredentials.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); + it.effect( "creates opaque credentials and revokes only older credentials for the same key", () => { @@ -47,7 +129,7 @@ describe("EnvironmentCredentials", () => { }), }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; @@ -87,7 +169,7 @@ describe("EnvironmentCredentials", () => { Effect.provide( EnvironmentCredentials.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -118,7 +200,7 @@ describe("EnvironmentCredentials", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; @@ -150,7 +232,7 @@ describe("EnvironmentCredentials", () => { Effect.provide( EnvironmentCredentials.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts index 13ced74c77a..39f40d941b8 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -8,33 +8,49 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { and, eq, isNull, ne, notExists } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts"; export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialCreatePersistenceError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals([ + "generate-credential", + "hash-token", + "insert-credential", + "revoke-previous-credentials", + ]), + environmentId: Schema.String, + credentialId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist environment credential"; + return `Environment credential creation failed during '${this.stage}' for environment '${this.environmentId}'${this.credentialId === undefined ? "" : `, credential '${this.credentialId}'`}`; } } export class EnvironmentCredentialAuthenticatePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialAuthenticatePersistenceError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals(["hash-token", "lookup-credential"]), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to authenticate environment credential"; + return `Environment credential authentication failed during '${this.stage}'`; } } export class EnvironmentCredentialRevokePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialRevokePersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to revoke environment credential"; + return `Failed to revoke credentials for environment '${this.environmentId}'`; } } @@ -44,30 +60,28 @@ export interface EnvironmentCredentialPrincipal { readonly environmentPublicKey: string; } -export interface EnvironmentCredentialsShape { - readonly create: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - }) => Effect.Effect; - readonly authenticate: ( - token: string, - ) => Effect.Effect< - Option.Option, - EnvironmentCredentialAuthenticatePersistenceError - >; - readonly revokeForEnvironmentPublicKey: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - }) => Effect.Effect; -} - export class EnvironmentCredentials extends Context.Service< EnvironmentCredentials, - EnvironmentCredentialsShape + { + readonly create: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect; + readonly authenticate: ( + token: string, + ) => Effect.Effect< + Option.Option, + EnvironmentCredentialAuthenticatePersistenceError + >; + readonly revokeForEnvironmentPublicKey: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect; + } >()("t3code-relay/environments/EnvironmentCredentials") {} const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; const crypto = yield* Crypto.Crypto; const hashToken = (token: string) => crypto @@ -87,13 +101,33 @@ const make = Effect.gen(function* () { }); return EnvironmentCredentials.of({ - create: Effect.fn("relay.environment_credentials.create")( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); - const credential = yield* makeCredential(); - const credentialHash = yield* hashToken(credential.token); - const now = DateTime.formatIso(yield* DateTime.now); - yield* db.insert(relayEnvironmentCredentials).values({ + create: Effect.fn("relay.environment_credentials.create")(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + const credential = yield* makeCredential().pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "generate-credential", + environmentId: input.environmentId, + cause, + }), + ), + ); + const credentialHash = yield* hashToken(credential.token).pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "hash-token", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + const now = DateTime.formatIso(yield* DateTime.now); + yield* db + .insert(relayEnvironmentCredentials) + .values({ credentialId: credential.credentialId, environmentId: input.environmentId, environmentPublicKey: input.environmentPublicKey, @@ -101,96 +135,136 @@ const make = Effect.gen(function* () { revokedAt: null, createdAt: now, updatedAt: now, - }); - yield* db - .update(relayEnvironmentCredentials) - .set({ - revokedAt: now, - updatedAt: now, - }) - .where( - and( - eq(relayEnvironmentCredentials.environmentId, input.environmentId), - eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), - ne(relayEnvironmentCredentials.credentialId, credential.credentialId), - isNull(relayEnvironmentCredentials.revokedAt), - ), - ); - return credential.token; - }, - Effect.mapError((cause) => new EnvironmentCredentialCreatePersistenceError({ cause })), - ), + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "insert-credential", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + yield* db + .update(relayEnvironmentCredentials) + .set({ + revokedAt: now, + updatedAt: now, + }) + .where( + and( + eq(relayEnvironmentCredentials.environmentId, input.environmentId), + eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), + ne(relayEnvironmentCredentials.credentialId, credential.credentialId), + isNull(relayEnvironmentCredentials.revokedAt), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "revoke-previous-credentials", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + return credential.token; + }), - authenticate: Effect.fn("relay.environment_credentials.authenticate")( - function* (token) { - const credentialHash = yield* hashToken(token); - const rows = yield* db - .select({ - credentialId: relayEnvironmentCredentials.credentialId, - environmentId: relayEnvironmentCredentials.environmentId, - environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey, + authenticate: Effect.fn("relay.environment_credentials.authenticate")(function* (token) { + const credentialHash = yield* hashToken(token).pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialAuthenticatePersistenceError({ + stage: "hash-token", + cause, + }), + ), + ); + const rows = yield* db + .select({ + credentialId: relayEnvironmentCredentials.credentialId, + environmentId: relayEnvironmentCredentials.environmentId, + environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey, + }) + .from(relayEnvironmentCredentials) + .where( + and( + eq(relayEnvironmentCredentials.credentialHash, credentialHash), + isNull(relayEnvironmentCredentials.revokedAt), + ), + ) + .limit(1) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialAuthenticatePersistenceError({ + stage: "lookup-credential", + cause, + }), + ), + ); + const row = rows[0]; + if (row) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId }); + } + return row + ? Option.some({ + credentialId: row.credentialId, + environmentId: row.environmentId, + environmentPublicKey: row.environmentPublicKey, }) - .from(relayEnvironmentCredentials) - .where( - and( - eq(relayEnvironmentCredentials.credentialHash, credentialHash), - isNull(relayEnvironmentCredentials.revokedAt), - ), - ) - .limit(1); - const row = rows[0]; - if (row) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId }); - } - return row - ? Option.some({ - credentialId: row.credentialId, - environmentId: row.environmentId, - environmentPublicKey: row.environmentPublicKey, - }) - : Option.none(); - }, - Effect.mapError((cause) => new EnvironmentCredentialAuthenticatePersistenceError({ cause })), - ), + : Option.none(); + }), revokeForEnvironmentPublicKey: Effect.fn( "relay.environment_credentials.revoke_for_environment_public_key", - )( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); - const revokedAt = DateTime.formatIso(yield* DateTime.now); - const rows = yield* db - .update(relayEnvironmentCredentials) - .set({ - revokedAt, - updatedAt: revokedAt, - }) - .where( - and( - eq(relayEnvironmentCredentials.environmentId, input.environmentId), - eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), - isNull(relayEnvironmentCredentials.revokedAt), - notExists( - db - .select({ userId: relayEnvironmentLinks.userId }) - .from(relayEnvironmentLinks) - .where( - and( - eq(relayEnvironmentLinks.environmentId, input.environmentId), - eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), - isNull(relayEnvironmentLinks.revokedAt), - ), + )(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + const revokedAt = DateTime.formatIso(yield* DateTime.now); + const rows = yield* db + .update(relayEnvironmentCredentials) + .set({ + revokedAt, + updatedAt: revokedAt, + }) + .where( + and( + eq(relayEnvironmentCredentials.environmentId, input.environmentId), + eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentCredentials.revokedAt), + notExists( + db + .select({ userId: relayEnvironmentLinks.userId }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.environmentId, input.environmentId), + eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentLinks.revokedAt), ), - ), + ), ), - ) - .returning({ - credentialId: relayEnvironmentCredentials.credentialId, - }); - return rows.length > 0; - }, - Effect.mapError((cause) => new EnvironmentCredentialRevokePersistenceError({ cause })), - ), + ), + ) + .returning({ + credentialId: relayEnvironmentCredentials.credentialId, + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialRevokePersistenceError({ + environmentId: input.environmentId, + cause, + }), + ), + ); + return rows.length > 0; + }), }); }); diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index dce364bffac..f6bd1c6d977 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayTokens from "../auth/RelayTokens.ts"; @@ -45,6 +46,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ managedEndpointBaseDomain: undefined, managedEndpointNamespace: undefined, }); +const isEnvironmentLinkProofInvalid = Schema.is(EnvironmentLinker.EnvironmentLinkProofInvalid); function signTestJwt(payload: object, typ: string, privateKey: string): string { const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url"); @@ -105,14 +107,14 @@ const makeRequest = Effect.gen(function* () { }); function testLayer(input?: { - readonly upsert?: EnvironmentLinks.EnvironmentLinksShape["upsert"]; - readonly consume?: DpopProofs.DpopProofReplayShape["consume"]; + readonly upsert?: EnvironmentLinks.EnvironmentLinks["Service"]["upsert"]; + readonly consume?: DpopProofs.DpopProofReplay["Service"]["consume"]; }) { return EnvironmentLinker.layer.pipe( Layer.provideMerge(RelayTokens.layer), Layer.provide( Layer.mergeAll( - Layer.succeed(RelayConfiguration.RelayConfiguration, config), + RelayConfiguration.layer(config), Layer.succeed(DpopProofs.DpopProofReplay, { verifyAndConsume: () => Effect.die("unexpected DPoP proof verification"), consume: input?.consume ?? (() => Effect.succeed(true)), @@ -182,6 +184,18 @@ describe("EnvironmentLinker", () => { const linker = yield* EnvironmentLinker.EnvironmentLinker; const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered })); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true); + if (isEnvironmentLinkProofInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + userId: "user_123", + environmentId: "env-link-test", + reason: "invalid_signature_or_scope", + stage: "verify_proof", + cause: { _tag: "RelayJwtError" }, + }); + } + } expect(persisted).toBe(false); }).pipe( Effect.provide( @@ -201,6 +215,17 @@ describe("EnvironmentLinker", () => { const linker = yield* EnvironmentLinker.EnvironmentLinker; const result = yield* Effect.result(linker.link({ userId: "user_123", request })); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true); + if (isEnvironmentLinkProofInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + userId: "user_123", + environmentId: "env-link-test", + reason: "replayed_nonce", + stage: "consume_proof_nonce", + }); + } + } }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))), ); }); diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts index 5eb12181692..6a97eefffa0 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -25,23 +25,40 @@ import * as RelayConfiguration from "../Config.ts"; export class EnvironmentLinkProofExpired extends Schema.TaggedErrorClass()( "EnvironmentLinkProofExpired", { + userId: Schema.String, + environmentId: Schema.String, expiresAt: Schema.String, }, ) { override get message(): string { - return `Environment link proof expired at ${this.expiresAt}`; + return `Environment '${this.environmentId}' link proof expired at ${this.expiresAt}`; } } export class EnvironmentLinkProofInvalid extends Schema.TaggedErrorClass()( "EnvironmentLinkProofInvalid", { + userId: Schema.String, environmentId: Schema.String, reason: RelayEnvironmentLinkProofInvalidReason, + stage: Schema.Literals([ + "decode_token", + "decode_payload", + "verify_proof", + "authorize_capabilities", + "validate_descriptor", + "verify_challenge", + "validate_expiration", + "consume_proof_nonce", + "consume_challenge_nonce", + "validate_origin", + "validate_endpoint", + ]), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Environment '${this.environmentId}' link proof is invalid: ${this.reason}`; + return `Environment '${this.environmentId}' link proof is invalid during ${this.stage}: ${this.reason}`; } } @@ -53,26 +70,25 @@ export type EnvironmentLinkError = | EnvironmentCredentials.EnvironmentCredentialCreatePersistenceError | ManagedEndpointProvider.ManagedEndpointProviderError; -export interface EnvironmentLinkerShape { - readonly link: (input: { - readonly userId: string; - readonly request: RelayEnvironmentLinkRequest; - }) => Effect.Effect< - { - readonly environmentId: RelayEnvironmentLinkProofPayload["environmentId"]; - readonly endpoint: RelayEnvironmentLinkProofPayload["endpoint"]; - readonly endpointRuntime: - | ManagedEndpointProvider.ManagedEndpointProvisioningResult["runtime"] - | null; - readonly environmentCredential: string; - }, - EnvironmentLinkError - >; -} - -export class EnvironmentLinker extends Context.Service()( - "t3code-relay/environments/EnvironmentLinker", -) {} +export class EnvironmentLinker extends Context.Service< + EnvironmentLinker, + { + readonly link: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkRequest; + }) => Effect.Effect< + { + readonly environmentId: RelayEnvironmentLinkProofPayload["environmentId"]; + readonly endpoint: RelayEnvironmentLinkProofPayload["endpoint"]; + readonly endpointRuntime: + | ManagedEndpointProvider.ManagedEndpointProvisioningResult["runtime"] + | null; + readonly environmentCredential: string; + }, + EnvironmentLinkError + >; + } +>()("t3code-relay/environments/EnvironmentLinker") {} const decodeProof = Schema.decodeUnknownEffect(RelayEnvironmentLinkProofPayload); @@ -133,20 +149,27 @@ const make = Effect.gen(function* () { const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); const unverified = yield* Effect.try({ try: () => decodeRelayJwt(input.request.proof), - catch: () => + catch: (cause) => new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: "unknown", reason: "invalid_signature_or_scope", + stage: "decode_token", + cause, }), }); - const decoded = yield* decodeProof(unverified).pipe(Effect.option); - if (decoded._tag === "None") { - return yield* new EnvironmentLinkProofInvalid({ - environmentId: "unknown", - reason: "invalid_signature_or_scope", - }); - } - const candidate = decoded.value; + const candidate = yield* decodeProof(unverified).pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkProofInvalid({ + userId: input.userId, + environmentId: "unknown", + reason: "invalid_signature_or_scope", + stage: "decode_payload", + cause, + }), + ), + ); yield* Effect.annotateCurrentSpan({ "relay.environment_id": candidate.environmentId, "relay.link.notifications_enabled": input.request.notificationsEnabled, @@ -155,6 +178,8 @@ const make = Effect.gen(function* () { }); if (candidate.exp <= nowSeconds) { return yield* new EnvironmentLinkProofExpired({ + userId: input.userId, + environmentId: candidate.environmentId, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)), }); } @@ -170,10 +195,13 @@ const make = Effect.gen(function* () { }).pipe( Effect.flatMap(decodeProof), Effect.mapError( - () => + (cause) => new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: candidate.environmentId, reason: "invalid_signature_or_scope", + stage: "verify_proof", + cause, }), ), ); @@ -182,14 +210,18 @@ const make = Effect.gen(function* () { !proofAuthorizesRequestedCapabilities(verified, input.request) ) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: candidate.environmentId, reason: "invalid_signature_or_scope", + stage: "authorize_capabilities", }); } if (verified.descriptor.environmentId !== verified.environmentId) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "descriptor_mismatch", + stage: "validate_descriptor", }); } const challenge = yield* relayTokens.verifyLinkChallenge({ @@ -204,15 +236,19 @@ const make = Effect.gen(function* () { }); if (challenge === null) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "challenge_invalid", + stage: "verify_challenge", }); } const expiresAt = DateTime.make(verified.exp * 1_000); if (expiresAt._tag === "None") { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "invalid_signature_or_scope", + stage: "validate_expiration", }); } const consumedNonce = yield* proofReplay.consume({ @@ -223,8 +259,10 @@ const make = Effect.gen(function* () { }); if (!consumedNonce) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "replayed_nonce", + stage: "consume_proof_nonce", }); } const consumedChallenge = yield* proofReplay.consume({ @@ -235,14 +273,18 @@ const make = Effect.gen(function* () { }); if (!consumedChallenge) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "challenge_invalid", + stage: "consume_challenge_nonce", }); } if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "origin_not_allowed", + stage: "validate_origin", }); } const provisioned = input.request.managedTunnelsEnabled @@ -255,8 +297,10 @@ const make = Effect.gen(function* () { const endpoint = provisioned?.endpoint ?? verified.endpoint; if (!isSecureManagedEndpoint(endpoint)) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "endpoint_not_secure", + stage: "validate_endpoint", }); } yield* links.upsert({ ...input, proof: verified, endpoint }); diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index b67dfb8e430..dccb9e39f60 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -3,11 +3,81 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { PgDialect } from "drizzle-orm/pg-core"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayEnvironmentLinks } from "../persistence/schema.ts"; -import { EnvironmentLinks, layer } from "./EnvironmentLinks.ts"; +import * as EnvironmentLinks from "./EnvironmentLinks.ts"; describe("EnvironmentLinks", () => { + it.effect("retains link lookup failures with user and environment identity", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const error = yield* Effect.flip( + links.getForUser({ userId: "user-1", environmentId: "env-1" }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentLinkLookupPersistenceError", + userId: "user-1", + environmentId: "env-1", + }); + expect(error.cause).toBe(cause); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + + it.effect("identifies delivery-user list failures without retaining key material", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + where: () => Effect.fail(cause), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const error = yield* Effect.flip( + links.listDeliveryUsersForEnvironment({ + environmentId: "env-1", + environmentPublicKey: "sensitive-public-key-material", + }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentLinkUserListPersistenceError", + operation: "list-delivery-users", + environmentId: "env-1", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("environmentPublicKey"); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + it.effect("selects users when either notifications or Live Activities are enabled", () => { const whereConditions: Array = []; const fakeDb = { @@ -25,10 +95,10 @@ describe("EnvironmentLinks", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { - const links = yield* EnvironmentLinks; + const links = yield* EnvironmentLinks.EnvironmentLinks; expect(yield* links.listUsersForEnvironment({ environmentId: "env-1" })).toEqual([]); expect(whereConditions).toHaveLength(1); @@ -39,7 +109,11 @@ describe("EnvironmentLinks", () => { expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3'); expect(query.sql).toContain(" or "); expect(query.params).toEqual(["env-1", true, true]); - }).pipe(Effect.provide(layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); }); it.effect("revokes only the active link owned by the requesting user", () => { @@ -65,10 +139,10 @@ describe("EnvironmentLinks", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { - const links = yield* EnvironmentLinks; + const links = yield* EnvironmentLinks.EnvironmentLinks; const revoked = yield* links.revokeForUser({ userId: "user-1", environmentId: "env-1", @@ -86,6 +160,10 @@ describe("EnvironmentLinks", () => { expect(query.sql).toContain('"relay_environment_links"."environment_id" = $2'); expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); expect(query.params).toEqual(["user-1", "env-1"]); - }).pipe(Effect.provide(layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); }); }); diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 9ed48c27905..6630af0a11b 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { and, eq, isNull, or } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayEnvironmentLinks } from "../persistence/schema.ts"; export interface RelayLinkedEnvironmentRecord extends RelayClientEnvironmentRecord { @@ -26,97 +26,119 @@ export interface AgentAwarenessDeliveryUserRecord { export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkUpsertPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + deviceId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist environment link"; + return `Failed to persist environment link for user '${this.userId}', environment '${this.environmentId}'`; } } export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkUserListPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["list-users", "list-delivery-users"]), + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list users linked to environment"; + return `Environment link user query '${this.operation}' failed for environment '${this.environmentId}'`; } } export class EnvironmentPublicKeyListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentPublicKeyListPersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list environment public keys"; + return `Failed to list public keys for environment '${this.environmentId}'`; } } export class EnvironmentLinkListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list environment links"; + return `Failed to list environment links for user '${this.userId}'`; } } export class EnvironmentLinkLookupPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkLookupPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to look up environment link"; + return `Failed to look up environment link for user '${this.userId}', environment '${this.environmentId}'`; } } export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkRevokePersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to revoke environment link"; + return `Failed to revoke environment link for user '${this.userId}', environment '${this.environmentId}'`; } } -export interface EnvironmentLinksShape { - readonly upsert: (input: { - readonly userId: string; - readonly request: RelayEnvironmentLinkRequest; - readonly proof: RelayEnvironmentLinkProofPayload; - readonly endpoint: RelayManagedEndpoint; - }) => Effect.Effect; - readonly listUsersForEnvironment: (input: { - readonly environmentId: string; - }) => Effect.Effect, EnvironmentLinkUserListPersistenceError>; - readonly listDeliveryUsersForEnvironment: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - }) => Effect.Effect< - ReadonlyArray, - EnvironmentLinkUserListPersistenceError - >; - readonly listPublicKeysForEnvironment: (input: { - readonly environmentId: string; - }) => Effect.Effect, EnvironmentPublicKeyListPersistenceError>; - readonly listForUser: (input: { - readonly userId: string; - }) => Effect.Effect< - ReadonlyArray, - EnvironmentLinkListPersistenceError - >; - readonly getForUser: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; - readonly revokeForUser: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; -} - -export class EnvironmentLinks extends Context.Service()( - "t3code-relay/environments/EnvironmentLinks", -) {} +export class EnvironmentLinks extends Context.Service< + EnvironmentLinks, + { + readonly upsert: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkRequest; + readonly proof: RelayEnvironmentLinkProofPayload; + readonly endpoint: RelayManagedEndpoint; + }) => Effect.Effect; + readonly listUsersForEnvironment: (input: { + readonly environmentId: string; + }) => Effect.Effect, EnvironmentLinkUserListPersistenceError>; + readonly listDeliveryUsersForEnvironment: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect< + ReadonlyArray, + EnvironmentLinkUserListPersistenceError + >; + readonly listPublicKeysForEnvironment: (input: { + readonly environmentId: string; + }) => Effect.Effect, EnvironmentPublicKeyListPersistenceError>; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect< + ReadonlyArray, + EnvironmentLinkListPersistenceError + >; + readonly getForUser: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + readonly revokeForUser: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + } +>()("t3code-relay/environments/EnvironmentLinks") {} function agentAwarenessDeliveryUserCondition(environmentId: string) { return and( @@ -140,25 +162,40 @@ function agentAwarenessDeliveryUserKeyCondition(input: { } const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; return EnvironmentLinks.of({ - upsert: Effect.fn("relay.environment_links.upsert")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.proof.environmentId, - }); - const now = DateTime.formatIso(yield* DateTime.now); - const { request, proof } = input; - const environmentId = proof.environmentId; - const { endpoint } = input; - yield* db - .insert(relayEnvironmentLinks) - .values({ - userId: input.userId, - environmentId, - environmentLabel: proof.descriptor.label, + upsert: Effect.fn("relay.environment_links.upsert")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.proof.environmentId, + }); + const now = DateTime.formatIso(yield* DateTime.now); + const { request, proof } = input; + const environmentId = proof.environmentId; + const { endpoint } = input; + yield* db + .insert(relayEnvironmentLinks) + .values({ + userId: input.userId, + environmentId, + environmentLabel: proof.descriptor.label, + environmentPublicKey: proof.environmentPublicKey, + endpointHttpBaseUrl: endpoint.httpBaseUrl, + endpointWsBaseUrl: endpoint.wsBaseUrl, + endpointProviderKind: endpoint.providerKind, + notificationsEnabled: request.notificationsEnabled, + liveActivitiesEnabled: request.liveActivitiesEnabled, + managedTunnelsEnabled: request.managedTunnelsEnabled, + createdByDeviceId: request.deviceId ?? null, + revokedAt: null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], + set: { environmentPublicKey: proof.environmentPublicKey, + environmentLabel: proof.descriptor.label, endpointHttpBaseUrl: endpoint.httpBaseUrl, endpointWsBaseUrl: endpoint.wsBaseUrl, endpointProviderKind: endpoint.providerKind, @@ -167,28 +204,21 @@ const make = Effect.gen(function* () { managedTunnelsEnabled: request.managedTunnelsEnabled, createdByDeviceId: request.deviceId ?? null, revokedAt: null, - createdAt: now, updatedAt: now, - }) - .onConflictDoUpdate({ - target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], - set: { - environmentPublicKey: proof.environmentPublicKey, - environmentLabel: proof.descriptor.label, - endpointHttpBaseUrl: endpoint.httpBaseUrl, - endpointWsBaseUrl: endpoint.wsBaseUrl, - endpointProviderKind: endpoint.providerKind, - notificationsEnabled: request.notificationsEnabled, - liveActivitiesEnabled: request.liveActivitiesEnabled, - managedTunnelsEnabled: request.managedTunnelsEnabled, - createdByDeviceId: request.deviceId ?? null, - revokedAt: null, - updatedAt: now, - }, - }); - }, - Effect.mapError((cause) => new EnvironmentLinkUpsertPersistenceError({ cause })), - ), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkUpsertPersistenceError({ + userId: input.userId, + environmentId, + ...(request.deviceId === undefined ? {} : { deviceId: request.deviceId }), + cause, + }), + ), + ); + }), listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")( function* (input) { @@ -199,7 +229,14 @@ const make = Effect.gen(function* () { .where(agentAwarenessDeliveryUserCondition(input.environmentId)) .pipe( Effect.map((rows) => rows.map((row) => row.userId)), - Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkUserListPersistenceError({ + operation: "list-users", + environmentId: input.environmentId, + cause, + }), + ), ); }, ), @@ -224,7 +261,14 @@ const make = Effect.gen(function* () { liveActivitiesEnabled: row.liveActivitiesEnabled, })), ), - Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkUserListPersistenceError({ + operation: "list-delivery-users", + environmentId: input.environmentId, + cause, + }), + ), ); }), @@ -245,7 +289,13 @@ const make = Effect.gen(function* () { Effect.map((rows) => [ ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)), ]), - Effect.mapError((cause) => new EnvironmentPublicKeyListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentPublicKeyListPersistenceError({ + environmentId: input.environmentId, + cause, + }), + ), ); }), @@ -281,7 +331,13 @@ const make = Effect.gen(function* () { linkedAt: row.createdAt, })), ), - Effect.mapError((cause) => new EnvironmentLinkListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkListPersistenceError({ + userId: input.userId, + cause, + }), + ), ); }), @@ -329,34 +385,48 @@ const make = Effect.gen(function* () { } : null; }), - Effect.mapError((cause) => new EnvironmentLinkLookupPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkLookupPersistenceError({ + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), ); }), - revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.environmentId, - }); - const revokedAt = DateTime.formatIso(yield* DateTime.now); - const rows = yield* db - .update(relayEnvironmentLinks) - .set({ - revokedAt, - updatedAt: revokedAt, - }) - .where( - and( - eq(relayEnvironmentLinks.userId, input.userId), - eq(relayEnvironmentLinks.environmentId, input.environmentId), - isNull(relayEnvironmentLinks.revokedAt), - ), - ) - .returning({ environmentId: relayEnvironmentLinks.environmentId }); - return rows.length > 0; - }, - Effect.mapError((cause) => new EnvironmentLinkRevokePersistenceError({ cause })), - ), + revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + }); + const revokedAt = DateTime.formatIso(yield* DateTime.now); + const rows = yield* db + .update(relayEnvironmentLinks) + .set({ + revokedAt, + updatedAt: revokedAt, + }) + .where( + and( + eq(relayEnvironmentLinks.userId, input.userId), + eq(relayEnvironmentLinks.environmentId, input.environmentId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .returning({ environmentId: relayEnvironmentLinks.environmentId }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkRevokePersistenceError({ + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), + ); + return rows.length > 0; + }), }); }); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index a74ce670cfb..f61c5a27d5b 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayConfiguration from "../Config.ts"; @@ -51,6 +52,9 @@ const state: RelayAgentActivityState = { updatedAt: "2026-05-25T00:00:00.000Z", deepLink: "/threads/env/thread", }; +const isEnvironmentPublishSignatureInvalid = Schema.is( + EnvironmentPublishSignatures.EnvironmentPublishSignatureInvalid, +); function signTestJwt(payload: object, privateKey: string): string { const header = Buffer.from( @@ -80,11 +84,11 @@ const freshRequest = Effect.gen(function* () { } satisfies RelayAgentActivityPublishRequest; }); -function layer(replay?: Partial) { +function layer(replay?: Partial) { return EnvironmentPublishSignatures.layer.pipe( Layer.provide( Layer.merge( - Layer.succeed(RelayConfiguration.RelayConfiguration, config), + RelayConfiguration.layer(config), Layer.succeed(DpopProofs.DpopProofReplay, { verifyAndConsume: replay?.verifyAndConsume ?? (() => Effect.die("unexpected DPoP proof verification")), @@ -145,6 +149,49 @@ describe("EnvironmentPublishSignatures", () => { }), ); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_claims", + }); + } + } + }).pipe(Effect.provide(layer())), + ); + + it.effect("preserves the JWT verification failure", () => + Effect.gen(function* () { + const request = yield* freshRequest; + const segments = request.proof.split("."); + const signature = segments[2]!; + segments[2] = `${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request: { ...request, proof: segments.join(".") }, + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "invalid_signature_or_payload", + stage: "verify_proof", + cause: { _tag: "RelayJwtError" }, + }); + } + } }).pipe(Effect.provide(layer())), ); @@ -161,6 +208,17 @@ describe("EnvironmentPublishSignatures", () => { }), ); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "replayed_nonce", + stage: "consume_nonce", + }); + } + } }).pipe(Effect.provide(layer({ consume: () => Effect.succeed(false) }))), ); }); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index 4d2d316b228..eb9c15a75aa 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -1,5 +1,6 @@ import { RelayAgentActivityPublishProofPayload, + RelayAgentActivityPublishProofInvalidReason, type RelayAgentActivityPublishRequest, } from "@t3tools/contracts/relay"; import { @@ -23,11 +24,13 @@ import * as RelayConfiguration from "../Config.ts"; export class EnvironmentPublishSignatureExpired extends Schema.TaggedErrorClass()( "EnvironmentPublishSignatureExpired", { + environmentId: Schema.String, + threadId: Schema.String, expiresAt: Schema.String, }, ) { override get message(): string { - return `Environment publish signature expired at ${this.expiresAt}`; + return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' expired at ${this.expiresAt}`; } } @@ -35,10 +38,21 @@ export class EnvironmentPublishSignatureInvalid extends Schema.TaggedErrorClass< "EnvironmentPublishSignatureInvalid", { environmentId: Schema.String, + threadId: Schema.String, + reason: RelayAgentActivityPublishProofInvalidReason, + stage: Schema.Literals([ + "decode_token", + "verify_proof", + "validate_claims", + "validate_expiration", + "generate_replay_thumbprint", + "consume_nonce", + ]), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Environment '${this.environmentId}' publish signature is invalid`; + return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' is invalid during ${this.stage}: ${this.reason}`; } } @@ -59,18 +73,16 @@ export type EnvironmentPublishSignatureError = | EnvironmentPublishPublicKeyMissing | DpopProofs.DpopProofReplayPersistenceError; -export interface EnvironmentPublishSignaturesShape { - readonly verify: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - readonly threadId: string; - readonly request: RelayAgentActivityPublishRequest; - }) => Effect.Effect; -} - export class EnvironmentPublishSignatures extends Context.Service< EnvironmentPublishSignatures, - EnvironmentPublishSignaturesShape + { + readonly verify: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + readonly request: RelayAgentActivityPublishRequest; + }) => Effect.Effect; + } >()("t3code-relay/environments/EnvironmentPublishSignatures") {} const decodeProof = Schema.decodeUnknownEffect(RelayAgentActivityPublishProofPayload); @@ -104,13 +116,22 @@ const make = Effect.gen(function* () { const now = yield* DateTime.now; const decoded = yield* Effect.try({ try: () => decodeRelayJwt(input.request.proof), - catch: () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + catch: (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "decode_token", + cause, + }), }); if ( typeof decoded.exp === "number" && decoded.exp <= Math.floor(now.epochMilliseconds / 1_000) ) { return yield* new EnvironmentPublishSignatureExpired({ + environmentId: input.environmentId, + threadId: input.threadId, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(decoded.exp * 1_000)), }); } @@ -124,7 +145,14 @@ const make = Effect.gen(function* () { }).pipe( Effect.flatMap(decodeProof), Effect.mapError( - () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "verify_proof", + cause, + }), ), ); if ( @@ -138,12 +166,18 @@ const make = Effect.gen(function* () { ) { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_claims", }); } const expiresAt = DateTime.make(proof.exp * 1_000); if (expiresAt._tag === "None") { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_expiration", }); } const thumbprint = yield* crypto @@ -157,7 +191,14 @@ const make = Effect.gen(function* () { .pipe( Effect.map(formatEnvironmentPublishReplayThumbprint), Effect.mapError( - () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "generate_replay_thumbprint", + cause, + }), ), ); const consumedNonce = yield* proofReplay.consume({ @@ -169,6 +210,9 @@ const make = Effect.gen(function* () { if (!consumedNonce) { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "replayed_nonce", + stage: "consume_nonce", }); } }), diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts new file mode 100644 index 00000000000..1a3c01d1e13 --- /dev/null +++ b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as RelayDb from "../db.ts"; +import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; +import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; + +const layerWithDb = (db: RelayDb.RelayDb["Service"]) => + ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))); + +describe("ManagedEndpointAllocations", () => { + it.effect("retains database failures with allocation operation and identity", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const error = yield* Effect.flip( + allocations.get({ userId: "user-1", environmentId: "environment-1" }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointAllocationPersistenceError", + operation: "get", + stage: "database-request", + userId: "user-1", + environmentId: "environment-1", + }); + expect(error.cause).toBe(cause); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); + + it.effect("reports an unresolved reservation without manufacturing a cause", () => { + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Effect.succeed([]), + }), + }), + }; + }, + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + where: () => ({ + limit: () => Effect.succeed([]), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const error = yield* Effect.flip( + allocations.reserve({ + userId: "user-1", + environmentId: "environment-1", + hostname: "environment-1.example.test", + tunnelName: "environment-1-tunnel", + }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointAllocationPersistenceError", + operation: "reserve", + stage: "resolve-reservation", + userId: "user-1", + environmentId: "environment-1", + hostname: "environment-1.example.test", + tunnelName: "environment-1-tunnel", + }); + expect(error.cause).toBeUndefined(); + expect(error.message).toContain("'resolve-reservation'"); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); +}); diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index 7809b43393e..f6cefa69071 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { isManagedEndpointHostname, managedEndpointForHostname } from "../deploymentConfig.ts"; import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; @@ -38,15 +38,29 @@ export function resolveReadyManagedEndpoint(input: { export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErrorClass()( "ManagedEndpointAllocationPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals([ + "get", + "reserve", + "record-tunnel", + "record-dns", + "mark-ready", + "remove", + ]), + stage: Schema.Literals(["database-request", "resolve-reservation"]), + userId: Schema.String, + environmentId: Schema.String, + hostname: Schema.optionalKey(Schema.String), + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, ) { override get message(): string { - return "Failed to persist managed endpoint allocation"; + return `Managed endpoint allocation '${this.operation}' failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } -const isManagedEndpointAllocationPersistenceError = Schema.is( - ManagedEndpointAllocationPersistenceError, -); interface ManagedEndpointAllocationKey { readonly userId: string; @@ -66,26 +80,29 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { readonly dnsRecordId: string; } -export interface ManagedEndpointAllocationsShape { - readonly get: ( - input: ManagedEndpointAllocationKey, - ) => Effect.Effect; - readonly reserve: ( - input: ReserveManagedEndpointAllocationInput, - ) => Effect.Effect; - readonly recordTunnel: ( - input: RecordManagedEndpointTunnelInput, - ) => Effect.Effect; - readonly recordDns: ( - input: RecordManagedEndpointDnsInput, - ) => Effect.Effect; - readonly markReady: ( - input: ManagedEndpointAllocationKey, - ) => Effect.Effect; - readonly remove: ( - input: ManagedEndpointAllocationKey, - ) => Effect.Effect; -} +export class ManagedEndpointAllocations extends Context.Service< + ManagedEndpointAllocations, + { + readonly get: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; + readonly reserve: ( + input: ReserveManagedEndpointAllocationInput, + ) => Effect.Effect; + readonly recordTunnel: ( + input: RecordManagedEndpointTunnelInput, + ) => Effect.Effect; + readonly recordDns: ( + input: RecordManagedEndpointDnsInput, + ) => Effect.Effect; + readonly markReady: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; + readonly remove: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; + } +>()("t3code-relay/environments/ManagedEndpointAllocations") {} const allocationSelection = { userId: relayManagedEndpointAllocations.userId, @@ -103,13 +120,8 @@ const whereAllocation = (input: ManagedEndpointAllocationKey) => eq(relayManagedEndpointAllocations.environmentId, input.environmentId), ); -const persistenceError = (cause: unknown) => - isManagedEndpointAllocationPersistenceError(cause) - ? cause - : new ManagedEndpointAllocationPersistenceError({ cause }); - -const make = Effect.gen(function* () { - const db = yield* RelayDb; +export const make = Effect.gen(function* () { + const db = yield* RelayDb.RelayDb; return ManagedEndpointAllocations.of({ get: Effect.fn("relay.managed_endpoint_allocations.get")(function* ( @@ -122,7 +134,15 @@ const make = Effect.gen(function* () { .limit(1) .pipe( Effect.map((rows) => rows[0] ?? null), - Effect.mapError(persistenceError), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "get", + stage: "database-request", + ...input, + cause, + }), + ), ); }), reserve: Effect.fn("relay.managed_endpoint_allocations.reserve")(function* ( @@ -137,7 +157,18 @@ const make = Effect.gen(function* () { updatedAt: now, }) .onConflictDoNothing() - .returning(allocationSelection); + .returning(allocationSelection) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "reserve", + stage: "database-request", + ...input, + cause, + }), + ), + ); const allocation = inserted[0] ?? @@ -146,16 +177,29 @@ const make = Effect.gen(function* () { .from(relayManagedEndpointAllocations) .where(whereAllocation(input)) .limit(1) - .pipe(Effect.map((rows) => rows[0]))); + .pipe( + Effect.map((rows) => rows[0]), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "reserve", + stage: "database-request", + ...input, + cause, + }), + ), + )); if (allocation === undefined) { return yield* new ManagedEndpointAllocationPersistenceError({ - cause: new Error("Managed endpoint allocation was not persisted."), + operation: "reserve", + stage: "resolve-reservation", + ...input, }); } return allocation; - }, Effect.mapError(persistenceError)), + }), recordTunnel: Effect.fn("relay.managed_endpoint_allocations.record_tunnel")(function* ( input: RecordManagedEndpointTunnelInput, ) { @@ -165,8 +209,19 @@ const make = Effect.gen(function* () { tunnelId: input.tunnelId, updatedAt: DateTime.formatIso(yield* DateTime.now), }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "record-tunnel", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), recordDns: Effect.fn("relay.managed_endpoint_allocations.record_dns")(function* ( input: RecordManagedEndpointDnsInput, ) { @@ -176,8 +231,19 @@ const make = Effect.gen(function* () { dnsRecordId: input.dnsRecordId, updatedAt: DateTime.formatIso(yield* DateTime.now), }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "record-dns", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), markReady: Effect.fn("relay.managed_endpoint_allocations.mark_ready")(function* ( input: ManagedEndpointAllocationKey, ) { @@ -188,19 +254,38 @@ const make = Effect.gen(function* () { readyAt: now, updatedAt: now, }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "mark-ready", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( input: ManagedEndpointAllocationKey, ) { - yield* db.delete(relayManagedEndpointAllocations).where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + yield* db + .delete(relayManagedEndpointAllocations) + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "remove", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), }); }); -export class ManagedEndpointAllocations extends Context.Service< - ManagedEndpointAllocations, - ManagedEndpointAllocationsShape ->()("t3code-relay/environments/ManagedEndpointAllocations") { - static readonly layer = Layer.effect(this, make); -} +export const layer = Layer.effect(ManagedEndpointAllocations, make); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index d9a9db9ce6b..56bf6319d0d 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -130,7 +130,10 @@ function makeDnsClient( calls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); if (!currentRecords.some((record) => record.id === dnsRecordId)) { return yield* new ManagedEndpointProvider.ManagedEndpointDnsClientError({ - cause: `DNS record ${dnsRecordId} does not exist.`, + operation: "update-record", + hostname: request.name, + dnsRecordId, + cause: { _tag: "NotFound", dnsRecordId }, }); } }), @@ -204,9 +207,9 @@ function providerLayer( ) { return ManagedEndpointProvider.layer.pipe( Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointTunnelClient, tunnelClient)), - Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointDnsClient, dnsClient)), + Layer.provide(RelayConfiguration.layer(config)), + Layer.provide(ManagedEndpointProvider.layerTunnelClient(tunnelClient)), + Layer.provide(ManagedEndpointProvider.layerDnsClient(dnsClient)), Layer.provide( Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations), ), @@ -398,7 +401,13 @@ describe("ManagedEndpointProvider", () => { expect(dnsCalls).toHaveLength(0); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { - expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); + expect(result.failure).toMatchObject({ + _tag: "ManagedEndpointOriginNotAllowed", + userId: "user_ABC", + environmentId: "env_ABC", + host: "192.168.1.10", + port: 3773, + }); } }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); }); @@ -522,6 +531,59 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); + it.effect("does not hide non-not-found checkpoint update failures", () => { + const dnsCalls: DnsCall[] = []; + const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "update-record", + dnsRecordId: "created-record-id", + cause: new Error("Cloudflare DNS unavailable"), + }); + let records: ReadonlyArray<{ readonly id: string }> = []; + const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + listRecords: (hostname) => + Effect.sync(() => { + dnsCalls.push({ operation: "listRecords", input: hostname }); + return records; + }), + createRecord: (request) => + Effect.sync(() => { + dnsCalls.push({ operation: "createRecord", input: request }); + const record = { id: "created-record-id" }; + records = [record]; + return record; + }), + updateRecord: (dnsRecordId, request) => + Effect.sync(() => { + dnsCalls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); + }).pipe(Effect.andThen(Effect.fail(failure))), + deleteRecord: () => Effect.void, + }); + const layer = providerLayer(makePersistentTunnelClient(), dnsClient, makeAllocations()); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const request = { + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + } as const; + yield* provider.provision(request); + const error = yield* Effect.flip(provider.provision(request)); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "ensure-dns-record", + userId: "user_ABC", + environmentId: "env_ABC", + }); + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listRecords", + "createRecord", + "updateRecord", + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect( "deprovisions checkpointed DNS and tunnel resources before removing the allocation", () => { @@ -593,6 +655,8 @@ describe("ManagedEndpointProvider", () => { const tunnelCalls: TunnelCall[] = []; let deleteAttempts = 0; const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId: "tunnel-id", cause: "Cloudflare tunnel deletion failed", }); const tunnels = makePersistentTunnelClient(tunnelCalls); @@ -618,6 +682,16 @@ describe("ManagedEndpointProvider", () => { }); const first = yield* Effect.result(provider.deprovision(key)); expect(first._tag).toBe("Failure"); + if (first._tag === "Failure") { + expect(first.failure).toMatchObject({ + _tag: "ManagedEndpointDeprovisioningFailed", + stage: "delete-tunnel", + userId: key.userId, + environmentId: key.environmentId, + tunnelId: "tunnel-id", + }); + expect(first.failure.cause).toBe(failure); + } yield* provider.deprovision(key); expect(allocationCalls.map((call) => call.operation)).toEqual([ @@ -639,13 +713,23 @@ describe("ManagedEndpointProvider", () => { ...makeTunnelClient(), delete: () => Effect.fail( - new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause: notFound }), + new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId: "tunnel-id", + cause: notFound, + }), ), }); const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ ...makeDnsClient(), deleteRecord: () => - Effect.fail(new ManagedEndpointProvider.ManagedEndpointDnsClientError({ cause: notFound })), + Effect.fail( + new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "delete-record", + dnsRecordId: "created-record-id", + cause: notFound, + }), + ), }); const layer = providerLayer(tunnelClient, dnsClient, makeAllocations(allocationCalls)); @@ -690,6 +774,8 @@ describe("ManagedEndpointProvider", () => { it.effect("recovers when DNS creation reports failure after the record became visible", () => { const dnsCalls: DnsCall[] = []; const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "create-record", + hostname: expectedManagedHostname("env_ABC"), cause: "ambiguous Cloudflare DNS response", }); let records: ReadonlyArray<{ readonly id: string }> = []; @@ -732,8 +818,44 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); }); + it.effect("reports mismatched tunnel responses without manufacturing a cause", () => { + const dnsCalls: DnsCall[] = []; + const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + ...makeTunnelClient(), + create: () => Effect.succeed({ id: "returned-tunnel-id", name: "unexpected-tunnel" }), + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const error = yield* Effect.flip( + provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "validate-tunnel-response", + userId: "user_ABC", + environmentId: "env_ABC", + hostname: expectedManagedHostname("env_ABC"), + tunnelName: expectedManagedTunnelName("env_ABC"), + returnedTunnelId: "returned-tunnel-id", + returnedTunnelName: "unexpected-tunnel", + }); + if (error._tag === "ManagedEndpointProvisioningFailed") { + expect(error.cause).toBeUndefined(); + } + expect(dnsCalls).toHaveLength(0); + }).pipe(Effect.provide(providerLayer(tunnelClient, makeDnsClient(dnsCalls)))); + }); + it.effect("fails provisioning when the DNS client fails", () => { const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "list-records", + hostname: expectedManagedHostname("env_ABC"), cause: "Cloudflare DNS failure", }); const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ @@ -753,8 +875,18 @@ describe("ManagedEndpointProvider", () => { }), ); - expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); - expect(error.cause).toBe(failure); + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "ensure-dns-record", + userId: "user_ABC", + environmentId: "env_ABC", + hostname: expectedManagedHostname("env_ABC"), + tunnelName: expectedManagedTunnelName("env_ABC"), + tunnelId: "tunnel-id", + }); + if (error._tag === "ManagedEndpointProvisioningFailed") { + expect(error.cause).toBe(failure); + } }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); }); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index bdbcc569dcb..68e93f8b17c 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -7,7 +7,6 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { @@ -23,44 +22,90 @@ import { managedEndpointHostname, managedEndpointTunnelName, } from "../deploymentConfig.ts"; -import { ManagedEndpointAllocations } from "./ManagedEndpointAllocations.ts"; +import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass()( "ManagedEndpointProvisioningNotConfigured", - {}, + { + userId: Schema.String, + environmentId: Schema.String, + missingSettings: Schema.Array( + Schema.Literals(["managedEndpointBaseDomain", "managedEndpointNamespace"]), + ), + }, ) { override get message(): string { - return "Managed endpoint provisioning is not configured"; + return `Managed endpoint provisioning is not configured for user '${this.userId}', environment '${this.environmentId}': missing ${this.missingSettings.join(", ")}`; } } +const ManagedEndpointProvisioningStage = Schema.Literals([ + "derive-environment-hash", + "reserve-allocation", + "ensure-tunnel", + "validate-tunnel-response", + "record-tunnel", + "configure-tunnel", + "ensure-dns-record", + "record-dns", + "get-tunnel-token", + "mark-allocation-ready", +]); + export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass()( "ManagedEndpointProvisioningFailed", - { cause: Schema.Defect() }, + { + stage: ManagedEndpointProvisioningStage, + userId: Schema.String, + environmentId: Schema.String, + hostname: Schema.optionalKey(Schema.String), + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + returnedTunnelName: Schema.optionalKey(Schema.String), + returnedTunnelId: Schema.optionalKey(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, ) { override get message(): string { - return "Managed endpoint provisioning failed"; + return `Managed endpoint provisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } +const ManagedEndpointDeprovisioningStage = Schema.Literals([ + "load-allocation", + "delete-dns-record", + "delete-tunnel", + "remove-allocation", +]); + export class ManagedEndpointDeprovisioningFailed extends Schema.TaggedErrorClass()( "ManagedEndpointDeprovisioningFailed", - { cause: Schema.Defect() }, + { + stage: ManagedEndpointDeprovisioningStage, + userId: Schema.String, + environmentId: Schema.String, + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint deprovisioning failed"; + return `Managed endpoint deprovisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } export class ManagedEndpointOriginNotAllowed extends Schema.TaggedErrorClass()( "ManagedEndpointOriginNotAllowed", { + userId: Schema.String, + environmentId: Schema.String, host: Schema.String, port: Schema.Number, }, ) { override get message(): string { - return `Managed endpoint origin '${this.host}:${this.port}' is not allowed`; + return `Managed endpoint origin '${this.host}:${this.port}' is not allowed for user '${this.userId}', environment '${this.environmentId}'`; } } @@ -74,21 +119,19 @@ export interface ManagedEndpointProvisioningResult { readonly runtime: RelayManagedEndpointRuntimeConfig; } -export interface ManagedEndpointProviderShape { - readonly provision: (input: { - readonly userId: string; - readonly environmentId: string; - readonly origin: RelayManagedEndpointOrigin; - }) => Effect.Effect; - readonly deprovision: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; -} - export class ManagedEndpointProvider extends Context.Service< ManagedEndpointProvider, - ManagedEndpointProviderShape + { + readonly provision: (input: { + readonly userId: string; + readonly environmentId: string; + readonly origin: RelayManagedEndpointOrigin; + }) => Effect.Effect; + readonly deprovision: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + } >()("t3code-relay/environments/ManagedEndpointProvider") {} interface ManagedEndpointTunnel { @@ -96,45 +139,62 @@ interface ManagedEndpointTunnel { readonly name?: string | null; } +const ManagedEndpointTunnelClientOperation = Schema.Literals([ + "list", + "create", + "put-configuration", + "get-token", + "delete", +]); + export class ManagedEndpointTunnelClientError extends Schema.TaggedErrorClass()( "ManagedEndpointTunnelClientError", - { cause: Schema.Defect() }, + { + operation: ManagedEndpointTunnelClientOperation, + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint tunnel provider request failed"; + const target = this.tunnelId ?? this.tunnelName; + return `Managed endpoint tunnel provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`; } } -export interface ManagedEndpointTunnelClientShape { - readonly list: (request: { - readonly name: string; - readonly isDeleted: false; - }) => Effect.Effect< - { readonly result: ReadonlyArray }, - ManagedEndpointTunnelClientError - >; - readonly create: (request: { - readonly name: string; - readonly configSrc: "cloudflare"; - }) => Effect.Effect; - readonly putConfiguration: ( - tunnelId: string, - config: { - readonly ingress: Array<{ - readonly hostname?: string; - readonly service: string; - }>; - }, - ) => Effect.Effect; - readonly getToken: (tunnelId: string) => Effect.Effect; - readonly delete: (tunnelId: string) => Effect.Effect; -} - export class ManagedEndpointTunnelClient extends Context.Service< ManagedEndpointTunnelClient, - ManagedEndpointTunnelClientShape + { + readonly list: (request: { + readonly name: string; + readonly isDeleted: false; + }) => Effect.Effect< + { readonly result: ReadonlyArray }, + ManagedEndpointTunnelClientError + >; + readonly create: (request: { + readonly name: string; + readonly configSrc: "cloudflare"; + }) => Effect.Effect; + readonly putConfiguration: ( + tunnelId: string, + config: { + readonly ingress: Array<{ + readonly hostname?: string; + readonly service: string; + }>; + }, + ) => Effect.Effect; + readonly getToken: ( + tunnelId: string, + ) => Effect.Effect; + readonly delete: (tunnelId: string) => Effect.Effect; + } >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {} +export const layerTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) => + Layer.succeed(ManagedEndpointTunnelClient, client); + interface ManagedEndpointCnameRecordInput { readonly type: "CNAME"; readonly name: string; @@ -143,45 +203,72 @@ interface ManagedEndpointCnameRecordInput { readonly proxied: true; } +const ManagedEndpointDnsClientOperation = Schema.Literals([ + "list-records", + "create-record", + "update-record", + "delete-record", +]); + export class ManagedEndpointDnsClientError extends Schema.TaggedErrorClass()( "ManagedEndpointDnsClientError", - { cause: Schema.Defect() }, + { + operation: ManagedEndpointDnsClientOperation, + hostname: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint DNS provider request failed"; + const target = this.dnsRecordId ?? this.hostname; + return `Managed endpoint DNS provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`; } } -export interface ManagedEndpointDnsClientShape { - readonly listRecords: ( - hostname: string, - ) => Effect.Effect, ManagedEndpointDnsClientError>; - readonly createRecord: ( - request: ManagedEndpointCnameRecordInput, - ) => Effect.Effect<{ readonly id: string }, ManagedEndpointDnsClientError>; - readonly updateRecord: ( - dnsRecordId: string, - request: ManagedEndpointCnameRecordInput, - ) => Effect.Effect; - readonly deleteRecord: ( - dnsRecordId: string, - ) => Effect.Effect; -} - export class ManagedEndpointDnsClient extends Context.Service< ManagedEndpointDnsClient, - ManagedEndpointDnsClientShape + { + readonly listRecords: ( + hostname: string, + ) => Effect.Effect, ManagedEndpointDnsClientError>; + readonly createRecord: ( + request: ManagedEndpointCnameRecordInput, + ) => Effect.Effect<{ readonly id: string }, ManagedEndpointDnsClientError>; + readonly updateRecord: ( + dnsRecordId: string, + request: ManagedEndpointCnameRecordInput, + ) => Effect.Effect; + readonly deleteRecord: ( + dnsRecordId: string, + ) => Effect.Effect; + } >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {} +export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) => + Layer.succeed(ManagedEndpointDnsClient, client); + const requireCloudflareSettings = Effect.fnUntraced(function* ( - settings: RelayConfiguration.RelayConfigurationShape, + settings: RelayConfiguration.RelayConfiguration["Service"], + input: { readonly userId: string; readonly environmentId: string }, ) { - if (!settings.managedEndpointBaseDomain || !settings.managedEndpointNamespace) { - return yield* new ManagedEndpointProvisioningNotConfigured(); + const baseDomain = settings.managedEndpointBaseDomain; + const namespace = settings.managedEndpointNamespace; + const missingSettings: Array<"managedEndpointBaseDomain" | "managedEndpointNamespace"> = []; + if (!baseDomain) { + missingSettings.push("managedEndpointBaseDomain"); + } + if (!namespace) { + missingSettings.push("managedEndpointNamespace"); + } + if (!baseDomain || !namespace) { + return yield* new ManagedEndpointProvisioningNotConfigured({ + ...input, + missingSettings, + }); } return { - baseDomain: settings.managedEndpointBaseDomain, - namespace: settings.managedEndpointNamespace, + baseDomain, + namespace, }; }); @@ -223,18 +310,27 @@ function isNotFoundCause(cause: unknown): boolean { return "cause" in cause && isNotFoundCause(cause.cause); } -const ignoreNotFound = (effect: Effect.Effect): Effect.Effect => +type ManagedEndpointClientError = ManagedEndpointTunnelClientError | ManagedEndpointDnsClientError; + +const ignoreNotFound = ( + effect: Effect.Effect, +): Effect.Effect => effect.pipe( Effect.asVoid, - Effect.catch((cause) => (isNotFoundCause(cause) ? Effect.void : Effect.fail(cause))), + Effect.catchTags({ + ManagedEndpointTunnelClientError: (error) => + isNotFoundCause(error.cause) ? Effect.void : Effect.fail(error), + ManagedEndpointDnsClientError: (error) => + isNotFoundCause(error.cause) ? Effect.void : Effect.fail(error), + }), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; const crypto = yield* Crypto.Crypto; const tunnels = yield* ManagedEndpointTunnelClient; const dns = yield* ManagedEndpointDnsClient; - const allocations = yield* ManagedEndpointAllocations; + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; const updateExistingDnsRecords = Effect.fnUntraced(function* ( records: ReadonlyArray<{ readonly id: string }>, @@ -264,7 +360,10 @@ const make = Effect.gen(function* () { .updateRecord(preferredDnsRecordId, dnsRecord) .pipe( Effect.as(true), - Effect.orElseSucceed(() => false), + Effect.catchTags({ + ManagedEndpointDnsClientError: (error) => + isNotFoundCause(error.cause) ? Effect.succeed(false) : Effect.fail(error), + }), ); if (checkpointedRecordUpdated) { return preferredDnsRecordId; @@ -281,25 +380,26 @@ const make = Effect.gen(function* () { } return yield* dns.createRecord(dnsRecord).pipe( Effect.map((record) => record.id), - Effect.catch((createError) => - Effect.gen(function* () { - let records = yield* dns.listRecords(hostname); - for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) { - yield* Effect.sleep("200 millis"); - records = yield* dns.listRecords(hostname); - } - return records; - }).pipe( - Effect.flatMap((records) => - records.length > 0 - ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) - : Effect.fail(createError), + Effect.catchTags({ + ManagedEndpointDnsClientError: (createError) => + Effect.gen(function* () { + let records = yield* dns.listRecords(hostname); + for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) { + yield* Effect.sleep("200 millis"); + records = yield* dns.listRecords(hostname); + } + return records; + }).pipe( + Effect.flatMap((records) => + records.length > 0 + ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) + : Effect.fail(createError), + ), + Effect.flatMap((dnsRecordId) => + dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), + ), ), - Effect.flatMap((dnsRecordId) => - dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), - ), - ), - ), + }), ); }); @@ -309,25 +409,59 @@ const make = Effect.gen(function* () { "relay.user_id": input.userId, "relay.environment_id": input.environmentId, }); - const allocation = yield* allocations - .get(input) - .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); + const allocation = yield* allocations.get(input).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "load-allocation", + cause, + }), + ), + ); if (allocation === null) { return; } - if (allocation.dnsRecordId !== null) { - yield* ignoreNotFound(dns.deleteRecord(allocation.dnsRecordId)).pipe( - Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), + const dnsRecordId = allocation.dnsRecordId; + if (dnsRecordId !== null) { + yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "delete-dns-record", + dnsRecordId, + cause, + }), + ), ); } - if (allocation.tunnelId !== null) { - yield* ignoreNotFound(tunnels.delete(allocation.tunnelId)).pipe( - Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), + const tunnelId = allocation.tunnelId; + if (tunnelId !== null) { + yield* ignoreNotFound(tunnels.delete(tunnelId)).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "delete-tunnel", + tunnelId, + cause, + }), + ), ); } - yield* allocations - .remove(input) - .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); + yield* allocations.remove(input).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "remove-allocation", + ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }), + ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), + cause, + }), + ), + ); }), provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -338,11 +472,13 @@ const make = Effect.gen(function* () { }); if (!isLoopbackOrigin(input.origin)) { return yield* new ManagedEndpointOriginNotAllowed({ + userId: input.userId, + environmentId: input.environmentId, host: input.origin.localHttpHost, port: input.origin.localHttpPort, }); } - const cf = yield* requireCloudflareSettings(config); + const cf = yield* requireCloudflareSettings(config, input); const environmentHash = yield* crypto .digest( "SHA-256", @@ -352,19 +488,45 @@ const make = Effect.gen(function* () { ) .pipe( Effect.map(Encoding.encodeHex), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "derive-environment-hash", + cause, + }), + ), ); + const requestedHostname = managedEndpointHostname( + cf.namespace, + cf.baseDomain, + environmentHash, + ); + const requestedTunnelName = managedEndpointTunnelName(cf.namespace, environmentHash); const allocation = yield* allocations .reserve({ userId: input.userId, environmentId: input.environmentId, - hostname: managedEndpointHostname(cf.namespace, cf.baseDomain, environmentHash), - tunnelName: managedEndpointTunnelName(cf.namespace, environmentHash), + hostname: requestedHostname, + tunnelName: requestedTunnelName, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "reserve-allocation", + hostname: requestedHostname, + tunnelName: requestedTunnelName, + cause, + }), + ), + ); const { hostname, tunnelName } = allocation; - const tunnel = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( + const tunnelResponse = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( Effect.map((tunnels) => tunnels.result), Effect.map(Arr.findFirst((tunnel) => tunnel.name === tunnelName)), Effect.flatMap( @@ -373,20 +535,50 @@ const make = Effect.gen(function* () { onNone: () => tunnels.create({ name: tunnelName, configSrc: "cloudflare" }), }), ), - Effect.filterMapOrFail((tunnel) => - tunnel.id && tunnel.name - ? Result.succeed({ id: tunnel.id, name: tunnel.name }) - : Result.fail(new ManagedEndpointProvisioningFailed({ cause: tunnel })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "ensure-tunnel", + hostname, + tunnelName, + cause, + }), ), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), ); + if (!tunnelResponse.id || tunnelResponse.name !== tunnelName) { + return yield* new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "validate-tunnel-response", + hostname, + tunnelName, + ...(tunnelResponse.id ? { returnedTunnelId: tunnelResponse.id } : {}), + ...(tunnelResponse.name ? { returnedTunnelName: tunnelResponse.name } : {}), + }); + } + const tunnel = { id: tunnelResponse.id, name: tunnelResponse.name }; yield* allocations .recordTunnel({ userId: input.userId, environmentId: input.environmentId, tunnelId: tunnel.id, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "record-tunnel", + hostname, + tunnelName, + tunnelId: tunnel.id, + cause, + }), + ), + ); yield* tunnels .putConfiguration(tunnel.id, { @@ -398,7 +590,20 @@ const make = Effect.gen(function* () { { service: "http_status:404" }, ], }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "configure-tunnel", + hostname, + tunnelName, + tunnelId: tunnel.id, + cause, + }), + ), + ); const dnsRecord = { type: "CNAME", @@ -409,7 +614,19 @@ const make = Effect.gen(function* () { } as const; const dnsRecordId = yield* ensureDnsRecord(hostname, allocation.dnsRecordId, dnsRecord).pipe( - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "ensure-dns-record", + hostname, + tunnelName, + tunnelId: tunnel.id, + ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), + cause, + }), + ), ); yield* allocations .recordDns({ @@ -417,17 +634,57 @@ const make = Effect.gen(function* () { environmentId: input.environmentId, dnsRecordId, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "record-dns", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); - const connectorToken = yield* tunnels - .getToken(tunnel.id) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + const connectorToken = yield* tunnels.getToken(tunnel.id).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "get-tunnel-token", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); yield* allocations .markReady({ userId: input.userId, environmentId: input.environmentId, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "mark-allocation-ready", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); return { endpoint: managedEndpointForHostname(hostname), @@ -452,69 +709,127 @@ export const layerCloudflareBindings = ( layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed( - ManagedEndpointTunnelClient, - ManagedEndpointTunnelClient.of({ - list: (request) => - tunnelClient.list(request).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + layerTunnelClient({ + list: (request) => + tunnelClient.list(request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "list", + tunnelName: request.name, + cause, + }), ), - create: (request) => - tunnelClient.create(request).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + create: (request) => + tunnelClient.create(request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "create", + tunnelName: request.name, + cause, + }), ), - putConfiguration: (tunnelId, config) => - tunnelClient.putConfiguration(tunnelId, config).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + putConfiguration: (tunnelId, config) => + tunnelClient.putConfiguration(tunnelId, config).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "put-configuration", + tunnelId, + cause, + }), ), - getToken: (tunnelId) => - tunnelClient.getToken(tunnelId).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + getToken: (tunnelId) => + tunnelClient.getToken(tunnelId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "get-token", + tunnelId, + cause, + }), ), - delete: (tunnelId) => - tunnelClient.delete(tunnelId).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + delete: (tunnelId) => + tunnelClient.delete(tunnelId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId, + cause, + }), ), - }), - ), - Layer.succeed( - ManagedEndpointDnsClient, - ManagedEndpointDnsClient.of({ - listRecords: (hostname) => - dnsClient.listDnsRecords({ search: hostname }).pipe( - Effect.map((response) => - response.result.filter( - (record): record is typeof record & { readonly id: string } => - typeof record.id === "string" && - normalizeHostname(record.name) === normalizeHostname(hostname), - ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), + layerDnsClient({ + listRecords: (hostname) => + dnsClient.listDnsRecords({ search: hostname }).pipe( + Effect.map((response) => + response.result.filter( + (record): record is typeof record & { readonly id: string } => + typeof record.id === "string" && + normalizeHostname(record.name) === normalizeHostname(hostname), ), - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), - createRecord: (request) => - dnsClient.createDnsRecord(request).pipe( - Effect.map((response) => ({ id: response.id })), - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "list-records", + hostname, + cause, + }), ), - updateRecord: (dnsRecordId, request) => - dnsClient.updateDnsRecord(dnsRecordId, request).pipe( - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + createRecord: (request) => + dnsClient.createDnsRecord(request).pipe( + Effect.map((response) => ({ id: response.id })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "create-record", + hostname: request.name, + cause, + }), ), - deleteRecord: (dnsRecordId) => - dnsClient.deleteDnsRecord(dnsRecordId).pipe( - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + updateRecord: (dnsRecordId, request) => + dnsClient.updateDnsRecord(dnsRecordId, request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "update-record", + hostname: request.name, + dnsRecordId, + cause, + }), ), - }), - ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + deleteRecord: (dnsRecordId) => + dnsClient.deleteDnsRecord(dnsRecordId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "delete-record", + dnsRecordId, + cause, + }), + ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), ), ), ); diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 6e7473b68d9..158bcec9803 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -30,7 +30,7 @@ vi.mock("@clerk/backend", () => ({ verifyToken: vi.fn(), })); -const relaySettings: RelayConfiguration.RelayConfigurationShape = { +const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = { relayIssuer: "https://relay.example.test", apns: { teamId: "apns-team", @@ -108,9 +108,10 @@ describe("relay client authentication", () => { describe("relay environment authentication", () => { it.effect("preserves credential lookup persistence failures as internal errors", () => { const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ + stage: "lookup-credential", cause: "database unavailable", }); - const credentials: EnvironmentCredentials.EnvironmentCredentialsShape = { + const credentials: EnvironmentCredentials.EnvironmentCredentials["Service"] = { create: () => Effect.die("unused create"), authenticate: () => Effect.fail(failure), revokeForEnvironmentPublicKey: () => Effect.die("unused revoke"), diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index fa2a2fec686..29e2026de3c 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -66,10 +66,9 @@ import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllo import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; import { withSpanAttributes } from "../observability.ts"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; -const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; const relayCorsAllowedHeaders = [ "authorization", "b3", @@ -239,13 +238,12 @@ export const relayEnvironmentAuthLayer = Layer.effect( { credential }, ) { const token = readHttpAuthorizationCredential(credential); - const principal = yield* credentials - .authenticate(token) - .pipe( - Effect.catchTag("EnvironmentCredentialAuthenticatePersistenceError", () => + const principal = yield* credentials.authenticate(token).pipe( + Effect.catchTags({ + EnvironmentCredentialAuthenticatePersistenceError: () => relayInternalErrorResponse("persistence_failed"), - ), - ); + }), + ); if (principal._tag === "None") { return yield* relayAuthInvalidError("not_authorized"); } @@ -348,7 +346,7 @@ export const healthApi = HttpApiBuilder.group( RelayApi, "health", Effect.fnUntraced(function* (handlers) { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; return handlers.handle( "health", Effect.fn("relay.api.health")( @@ -599,7 +597,7 @@ export const tokenApi = HttpApiBuilder.group( Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), ); const now = yield* DateTime.now; - const expiresAt = DateTime.addDuration(now, RELAY_DPOP_ACCESS_TOKEN_TTL); + const expiresAt = DateTime.addDuration(now, RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL); const jti = yield* crypto.randomUUIDv4.pipe( Effect.catch(() => relayInternalErrorResponse("internal_error")), ); @@ -617,7 +615,7 @@ export const tokenApi = HttpApiBuilder.group( .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), issued_token_type: RelayAccessTokenType, token_type: "DPoP" as const, - expires_in: Duration.toSeconds(RELAY_DPOP_ACCESS_TOKEN_TTL), + expires_in: Duration.toSeconds(RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL), scope: encodeOAuthScope(requestedScopes), }; }, mapRelayCommonApiErrors("invalid_dpop")), @@ -778,7 +776,61 @@ export const serverApi = HttpApiBuilder.group( reason: "persistence_failed", traceId, }), - ApnsDeliveryJobInvalid: (_error, traceId) => + ApnsDeliveryJobQueuePayloadInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobLiveActivityAggregateMissing: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobLiveActivityNotificationUnexpected: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobPushNotificationMissing: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobPushNotificationAggregateUnexpected: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobCreatedAtInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobExpiresAtInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobTimeWindowInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobTimeWindowTooLong: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobSignatureInvalid: (_error, traceId) => new RelayInternalError({ code: "internal_error", reason: "internal_error", @@ -986,7 +1038,10 @@ function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): audience.some((entry) => typeof entry === "string" && entry === expectedAudience); } -function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationShape, token: string) { +function verifyClerkBearerToken( + config: RelayConfiguration.RelayConfiguration["Service"], + token: string, +) { return Effect.tryPromise({ try: () => verifyToken(token, { @@ -1002,7 +1057,7 @@ function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationSha } function verifyClerkOAuthBearerToken( - config: RelayConfiguration.RelayConfigurationShape, + config: RelayConfiguration.RelayConfiguration["Service"], token: string, ) { return Effect.tryPromise({ @@ -1028,7 +1083,7 @@ function verifyClerkOAuthBearerToken( } export function verifyRelayClientBearerToken( - config: RelayConfiguration.RelayConfigurationShape, + config: RelayConfiguration.RelayConfiguration["Service"], token: string, ) { return verifyClerkBearerToken(config, token).pipe( diff --git a/infra/relay/src/observability.test.ts b/infra/relay/src/observability.test.ts index ff543672f7f..5daeda11660 100644 --- a/infra/relay/src/observability.test.ts +++ b/infra/relay/src/observability.test.ts @@ -9,7 +9,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import type { OtlpTracer } from "effect/unstable/observability"; -import { EnvironmentConnectNotAuthorized } from "./environments/EnvironmentConnector.ts"; +import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; import { makeRelayTraceLayer } from "./observability.ts"; interface ExportedRequest { @@ -43,7 +43,7 @@ it.effect("exports schema error fields as span attributes", () => ); yield* Effect.fail( - new EnvironmentConnectNotAuthorized({ + new EnvironmentConnector.EnvironmentConnectNotAuthorized({ environmentId: "environment-1", operation: "connect", reason: "managed_endpoint_allocation_not_ready", diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 2c11d5066ec..0b4f1d1bbc0 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -42,7 +42,7 @@ import * as EnvironmentCredentials from "./environments/EnvironmentCredentials.t import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts"; import * as LiveActivities from "./agentActivity/LiveActivities.ts"; -import { RelayDb, RelayHyperdrive } from "./db.ts"; +import * as RelayDb from "./db.ts"; import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; @@ -138,7 +138,7 @@ export default class Api extends Cloudflare.Worker()( const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; - const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); + const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayDb.RelayHyperdrive); const db = yield* Drizzle.postgres(hyperdrive.connectionString); const managedEndpointTunnelBinding = yield* Cloudflare.TunnelReadWrite.bind(); @@ -203,16 +203,11 @@ export default class Api extends Cloudflare.Worker()( Layer.provideMerge(AgentActivityRows.layer), Layer.provideMerge(Devices.layer), Layer.provideMerge(EnvironmentCredentials.layer), - Layer.provideMerge( - Layer.mergeAll( - EnvironmentLinks.layer, - ManagedEndpointAllocations.ManagedEndpointAllocations.layer, - ), - ), + Layer.provideMerge(Layer.mergeAll(EnvironmentLinks.layer, ManagedEndpointAllocations.layer)), Layer.provideMerge(LiveActivities.layer), Layer.provideMerge(DeliveryAttempts.layer), Layer.provideMerge(RelayTokens.layer), - Layer.provideMerge(Layer.succeed(RelayDb, db)), + Layer.provideMerge(Layer.succeed(RelayDb.RelayDb, db)), Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)), Layer.provideMerge(webcryptoLayer), ); diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index b8db9e16a36..400785be043 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -1,5 +1,6 @@ import { definePlugin } from "@oxlint/plugins"; +import namespaceNodeImports from "./rules/namespace-node-imports.ts"; import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; @@ -9,6 +10,7 @@ export default definePlugin({ name: "t3code", }, rules: { + "namespace-node-imports": namespaceNodeImports, "no-global-process-runtime": noGlobalProcessRuntime, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, diff --git a/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts b/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts new file mode 100644 index 00000000000..c097264ba8e --- /dev/null +++ b/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts @@ -0,0 +1,63 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/namespace-node-imports"); + +describe("t3code/namespace-node-imports", () => { + rule.valid( + "allows canonical Node namespaces", + ` + import * as NodeFS from "node:fs"; + import * as NodeFSP from "node:fs/promises"; + import * as NodeAssert from "node:assert/strict"; + import * as NodeChildProcess from "node:child_process"; + import * as NodeTimersPromises from "node:timers/promises"; + import type * as NodeStream from "node:stream"; + + NodeAssert.ok(NodeChildProcess.spawn && NodeTimersPromises.setTimeout); + export const read = NodeFS.readFileSync; + export const readAsync = NodeFSP.readFile; + export type Input = NodeStream.Readable; + `, + ); + + rule.valid( + "does not apply to non-Node packages", + ` + import { BrowserWindow } from "electron"; + `, + ); + + rule.invalid( + "reports named imports", + ` + import { readFile } from "node:fs/promises"; + `, + (output) => { + assert.match(output, /namespace named NodeFSP/); + }, + ); + + rule.invalid( + "reports default imports", + ` + import path from "node:path"; + `, + (output) => { + assert.match(output, /namespace named NodePath/); + }, + ); + + rule.invalid( + "reports non-canonical namespace aliases", + ` + import * as Crypto from "node:crypto"; + import * as NodeOs from "node:os"; + `, + (output) => { + assert.match(output, /namespace named NodeCrypto/); + assert.match(output, /namespace named NodeOS/); + }, + ); +}); diff --git a/oxlint-plugin-t3code/rules/namespace-node-imports.ts b/oxlint-plugin-t3code/rules/namespace-node-imports.ts new file mode 100644 index 00000000000..07d73dcf6b6 --- /dev/null +++ b/oxlint-plugin-t3code/rules/namespace-node-imports.ts @@ -0,0 +1,76 @@ +import { defineRule } from "@oxlint/plugins"; + +const NODE_MODULE_ALIASES = new Map([ + ["assert/strict", "Assert"], + ["fs/promises", "FSP"], +]); + +const NODE_SEGMENT_ALIASES = new Map([ + ["fs", "FS"], + ["os", "OS"], + ["url", "URL"], + ["vm", "VM"], +]); + +const toPascalCase = (value: string) => + value + .split(/[_-]/u) + .filter((segment) => segment.length > 0) + .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) + .join(""); + +const expectedNamespaceAlias = (source: string) => { + const moduleName = source.slice("node:".length); + const knownAlias = NODE_MODULE_ALIASES.get(moduleName); + if (knownAlias !== undefined) return `Node${knownAlias}`; + + return `Node${moduleName + .split("/") + .map((segment) => NODE_SEGMENT_ALIASES.get(segment) ?? toPascalCase(segment)) + .join("")}`; +}; + +const literalStringValue = (node: unknown): string | undefined => { + if (typeof node !== "object" || node === null) return undefined; + if (!("type" in node) || node.type !== "Literal") return undefined; + if (!("value" in node) || typeof node.value !== "string") return undefined; + return node.value; +}; + +const identifierName = (node: unknown): string | undefined => { + if (typeof node !== "object" || node === null) return undefined; + if (!("type" in node) || node.type !== "Identifier") return undefined; + if (!("name" in node) || typeof node.name !== "string") return undefined; + return node.name; +}; + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: "Require canonical namespace imports for Node.js built-in modules.", + }, + }, + create(context) { + return { + ImportDeclaration(node) { + const source = literalStringValue(node.source); + if (source === undefined || !source.startsWith("node:")) return; + + const expectedAlias = expectedNamespaceAlias(source); + const namespaceImport = + node.specifiers.length === 1 && node.specifiers[0]?.type === "ImportNamespaceSpecifier" + ? node.specifiers[0] + : undefined; + const actualAlias = identifierName(namespaceImport?.local); + + if (actualAlias === expectedAlias) return; + + context.report({ + node, + message: `Import ${source} as a namespace named ${expectedAlias}.`, + }); + }, + }; + }, +}); diff --git a/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts b/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts index f34ded78a56..7cb316001a7 100644 --- a/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts +++ b/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts @@ -13,22 +13,26 @@ const COMPILER_METHODS = new Set([ "decodeExit", "decodeOption", "decodePromise", + "decodeResult", "decodeSync", "decodeUnknownExit", "decodeUnknownEffect", "decodeUnknownOption", "decodeUnknownPromise", + "decodeUnknownResult", "decodeUnknownSync", "encodeExit", "encodeEffect", "encodeOption", "encodePromise", + "encodeResult", "encodeSync", "encodeUnknownExit", "encodeUnknownEffect", "encodeUnknownOption", "encodeUnknownPromise", + "encodeUnknownResult", "encodeUnknownSync", ]); diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index fb0ca1c65f5..e6eff1e5c21 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -25,7 +25,6 @@ const LEGACY_BASELINE = new Map([ ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1], ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2], ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2], - ["apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts", 5], ["apps/server/src/orchestration/commandInvariants.test.ts", 6], ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42], ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5], @@ -48,7 +47,7 @@ const LEGACY_BASELINE = new Map([ ["apps/web/src/cloud/dpop.test.ts", 2], ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1], ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7], - ["packages/client-runtime/src/managedRelayState.test.ts", 1], + ["packages/client-runtime/src/relay/managedRelayState.test.ts", 1], ["packages/client-runtime/src/wsTransport.test.ts", 2], ]); diff --git a/packages/client-runtime/README.md b/packages/client-runtime/README.md new file mode 100644 index 00000000000..722d6f6d389 --- /dev/null +++ b/packages/client-runtime/README.md @@ -0,0 +1,31 @@ +# Client Runtime + +Shared client behavior for web and mobile. Public APIs are organized by package +subpath. The package intentionally has no root export. + +## Public subpaths + +| Subpath | Responsibility | +| --------------------- | ---------------------------------------------------------------- | +| `authorization` | Bearer and DPoP authorization plus token persistence contracts | +| `connection` | Targets, catalog, supervision, retries, registry, and onboarding | +| `environment` | Environment identity, descriptors, endpoints, and scoped keys | +| `errors` | Shared client error inspection | +| `operations` | Multi-step application workflows | +| `operations/projects` | Multi-step project creation workflows | +| `platform` | Platform capability and persistence service contracts | +| `relay` | Managed relay API and environment discovery | +| `rpc` | HTTP/RPC clients, protocol, sessions, and subscriptions | +| `state/` | Focused shared state, retention, reducers, and Atom constructors | + +## Dependency direction + +Platform applications provide `platform` services. `connection` composes those +capabilities with `authorization`, `relay`, and `rpc` to supervise environment +sessions. Independent `state` modules consume the connection registry and expose +focused state or Atom constructors to application-owned runtimes. + +Applications should import the narrowest relevant subpath. There is no broad +`state` export: use domain paths such as `state/shell`, `state/threads`, +`state/terminal`, or `state/vcs`. Subpath indices and explicitly exported domain +files are public API boundaries; all other files remain implementation details. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index bf1c1bdc0c0..d9e19889721 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -2,15 +2,134 @@ "name": "@t3tools/client-runtime", "private": true, "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", "exports": { - ".": { - "types": "./src/index.ts", - "react-native": "./src/index.ts", - "import": "./src/index.ts", - "require": "./src/index.ts", - "default": "./src/index.ts" + "./connection": { + "types": "./src/connection/index.ts", + "default": "./src/connection/index.ts" + }, + "./authorization": { + "types": "./src/authorization/index.ts", + "default": "./src/authorization/index.ts" + }, + "./environment": { + "types": "./src/environment/index.ts", + "default": "./src/environment/index.ts" + }, + "./errors": { + "types": "./src/errors/index.ts", + "default": "./src/errors/index.ts" + }, + "./rpc": { + "types": "./src/rpc/index.ts", + "default": "./src/rpc/index.ts" + }, + "./operations": { + "types": "./src/operations/index.ts", + "default": "./src/operations/index.ts" + }, + "./operations/projects": { + "types": "./src/operations/projects.ts", + "default": "./src/operations/projects.ts" + }, + "./platform": { + "types": "./src/platform/index.ts", + "default": "./src/platform/index.ts" + }, + "./relay": { + "types": "./src/relay/index.ts", + "default": "./src/relay/index.ts" + }, + "./state/auth": { + "types": "./src/state/auth.ts", + "default": "./src/state/auth.ts" + }, + "./state/assets": { + "types": "./src/state/assets.ts", + "default": "./src/state/assets.ts" + }, + "./state/connections": { + "types": "./src/state/connections.ts", + "default": "./src/state/connections.ts" + }, + "./state/entities": { + "types": "./src/state/entities.ts", + "default": "./src/state/entities.ts" + }, + "./state/filesystem": { + "types": "./src/state/filesystem.ts", + "default": "./src/state/filesystem.ts" + }, + "./state/git": { + "types": "./src/state/git.ts", + "default": "./src/state/git.ts" + }, + "./state/models": { + "types": "./src/state/models.ts", + "default": "./src/state/models.ts" + }, + "./state/orchestration": { + "types": "./src/state/orchestration.ts", + "default": "./src/state/orchestration.ts" + }, + "./state/presentation": { + "types": "./src/state/presentation.ts", + "default": "./src/state/presentation.ts" + }, + "./state/preview": { + "types": "./src/state/preview.ts", + "default": "./src/state/preview.ts" + }, + "./state/projects": { + "types": "./src/state/projects.ts", + "default": "./src/state/projects.ts" + }, + "./state/project-grouping": { + "types": "./src/state/projectGrouping.ts", + "default": "./src/state/projectGrouping.ts" + }, + "./state/relay": { + "types": "./src/state/relayDiscovery.ts", + "default": "./src/state/relayDiscovery.ts" + }, + "./state/review": { + "types": "./src/state/review.ts", + "default": "./src/state/review.ts" + }, + "./state/runtime": { + "types": "./src/state/runtime.ts", + "default": "./src/state/runtime.ts" + }, + "./state/server": { + "types": "./src/state/server.ts", + "default": "./src/state/server.ts" + }, + "./state/session": { + "types": "./src/state/session.ts", + "default": "./src/state/session.ts" + }, + "./state/shell": { + "types": "./src/state/shell.ts", + "default": "./src/state/shell.ts" + }, + "./state/source-control": { + "types": "./src/state/sourceControl.ts", + "default": "./src/state/sourceControl.ts" + }, + "./state/terminal": { + "types": "./src/state/terminal.ts", + "default": "./src/state/terminal.ts" + }, + "./state/threads": { + "types": "./src/state/threads.ts", + "default": "./src/state/threads.ts" + }, + "./state/thread-sort": { + "types": "./src/state/threadSort.ts", + "default": "./src/state/threadSort.ts" + }, + "./state/vcs": { + "types": "./src/state/vcs.ts", + "default": "./src/state/vcs.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/advertisedEndpoint.ts b/packages/client-runtime/src/advertisedEndpoint.ts deleted file mode 100644 index da7d766fa80..00000000000 --- a/packages/client-runtime/src/advertisedEndpoint.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@t3tools/shared/advertisedEndpoint"; diff --git a/packages/client-runtime/src/archivedThreadsState.test.ts b/packages/client-runtime/src/archivedThreadsState.test.ts deleted file mode 100644 index 3a819fa30b9..00000000000 --- a/packages/client-runtime/src/archivedThreadsState.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type ArchivedThreadsClient, - createArchivedThreadsManager, - makeArchivedThreadsEnvironmentKey, - parseArchivedThreadsEnvironmentKey, - readArchivedThreadsSnapshotState, -} from "./archivedThreadsState.ts"; - -let registry = AtomRegistry.make(); - -function resetAtomRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -function createSnapshot(id: string): OrchestrationShellSnapshot { - return { - snapshotSequence: 1, - projects: [], - threads: [], - updatedAt: `2026-05-08T00:00:00.000Z`, - id, - } as OrchestrationShellSnapshot; -} - -describe("createArchivedThreadsManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads archived snapshots for configured environment clients", async () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const clients = new Map([ - [ - envA, - { - getArchivedShellSnapshot: vi.fn(async () => createSnapshot("a")), - }, - ], - [ - envB, - { - getArchivedShellSnapshot: vi.fn(async () => createSnapshot("b")), - }, - ], - ]); - const manager = createArchivedThreadsManager({ - getRegistry: () => registry, - getClient: (environmentId) => clients.get(environmentId) ?? null, - }); - - const result = registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envB, envA]))); - - await vi.waitFor(() => { - const state = readArchivedThreadsSnapshotState( - registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB]))), - ); - expect(state.snapshots.map((snapshot) => snapshot.environmentId)).toEqual([envA, envB]); - }); - expect(readArchivedThreadsSnapshotState(result).isLoading).toBe(true); - }); - - it("refreshes known snapshot groups that include an environment", async () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const getArchivedShellSnapshot = vi.fn(async () => - createSnapshot(`a-${getArchivedShellSnapshot.mock.calls.length}`), - ); - const manager = createArchivedThreadsManager({ - getRegistry: () => registry, - getClient: (environmentId) => (environmentId === envA ? { getArchivedShellSnapshot } : null), - staleTimeMs: 60_000, - }); - - const atom = manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB])); - registry.get(atom); - await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(1)); - - manager.refreshForEnvironment(envA); - - await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(2)); - }); - - it("round-trips environment keys in sorted order", () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const key = makeArchivedThreadsEnvironmentKey([envB, envA]); - - expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); - }); -}); diff --git a/packages/client-runtime/src/archivedThreadsState.ts b/packages/client-runtime/src/archivedThreadsState.ts deleted file mode 100644 index b1d6ec59e4e..00000000000 --- a/packages/client-runtime/src/archivedThreadsState.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type ArchivedSnapshotEntry = { - readonly environmentId: EnvironmentId; - readonly snapshot: OrchestrationShellSnapshot; -}; - -export interface ArchivedThreadsClient { - readonly getArchivedShellSnapshot: () => Promise; -} - -export interface ArchivedThreadsSnapshotState { - readonly snapshots: ReadonlyArray; - readonly error: string | null; - readonly isLoading: boolean; -} - -const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; -const DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS = 5_000; -const DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000; -const environmentIdOrder = Order.String as Order.Order; - -export function makeArchivedThreadsEnvironmentKey( - environmentIds: ReadonlyArray, -): string { - return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => - sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), - ); -} - -export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { - if (key.length === 0) { - return []; - } - return pipe( - key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), - Arr.map((environmentId) => EnvironmentId.make(environmentId)), - ); -} - -export function readArchivedThreadsSnapshotState( - result: AsyncResult.AsyncResult, unknown>, -): ArchivedThreadsSnapshotState { - const snapshots = Option.getOrElse(AsyncResult.value(result), () => []); - let error: string | null = null; - if (result._tag === "Failure") { - const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Failed to load archived threads."; - } - - return { - snapshots, - error, - isLoading: result.waiting, - }; -} - -export function createArchivedThreadsManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ArchivedThreadsClient | null; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -}) { - const knownEnvironmentKeys = new Set(); - const knownEnvironmentIdsByKey = new Map>(); - const staleTime = config.staleTimeMs ?? DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS; - const idleTtl = config.idleTtlMs ?? DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS; - - const snapshotsAtom = Atom.family((environmentKey: string) => { - knownEnvironmentKeys.add(environmentKey); - knownEnvironmentIdsByKey.set( - environmentKey, - new Set(parseArchivedThreadsEnvironmentKey(environmentKey)), - ); - return Atom.make( - Effect.promise(async (): Promise> => { - const snapshots = await Promise.all( - pipe( - parseArchivedThreadsEnvironmentKey(environmentKey), - Arr.map(async (environmentId) => { - const client = config.getClient(environmentId); - if (!client) { - return null; - } - return { - environmentId, - snapshot: await client.getArchivedShellSnapshot(), - }; - }), - ), - ); - return pipe( - snapshots, - Arr.filterMap((snapshot) => - snapshot !== null ? Result.succeed(snapshot) : Result.failVoid, - ), - ); - }), - ).pipe( - Atom.swr({ - staleTime, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtl), - Atom.withLabel(`archived-thread-snapshots:${environmentKey}`), - ); - }); - - function getAtom(environmentKey: string) { - return snapshotsAtom(environmentKey); - } - - function refresh(environmentIds: ReadonlyArray): void { - config.getRegistry().refresh(getAtom(makeArchivedThreadsEnvironmentKey(environmentIds))); - } - - function refreshForEnvironment(environmentId: EnvironmentId): void { - for (const environmentKey of knownEnvironmentKeys) { - if (knownEnvironmentIdsByKey.get(environmentKey)?.has(environmentId)) { - config.getRegistry().refresh(getAtom(environmentKey)); - } - } - } - - return { - getAtom, - refresh, - refreshForEnvironment, - }; -} diff --git a/packages/client-runtime/src/authorization/index.ts b/packages/client-runtime/src/authorization/index.ts new file mode 100644 index 00000000000..6236b5922d8 --- /dev/null +++ b/packages/client-runtime/src/authorization/index.ts @@ -0,0 +1,7 @@ +export * from "./remote.ts"; +export { + type AuthorizedRemoteEnvironment, + type RelayEnvironmentAuthorization, + RemoteEnvironmentAuthorization, +} from "./service.ts"; +export * as TokenStore from "./tokenStore.ts"; diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts new file mode 100644 index 00000000000..1d2c6c6cca7 --- /dev/null +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -0,0 +1,342 @@ +import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import * as ManagedRelay from "../relay/managedRelay.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as RemoteEnvironmentAuthorization from "./service.ts"; +import * as TokenStore from "./tokenStore.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const ENDPOINT = { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel" as const, +}; +const DESCRIPTOR = { + environmentId: ENVIRONMENT_ID, + label: "Remote environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; +const BOOTSTRAP: RemoteEnvironmentAuthorization.RelayEnvironmentAuthorization = { + environmentId: ENVIRONMENT_ID, + endpoint: ENDPOINT, + credential: "relay-bootstrap", +}; + +function recordedFetch(responses: ReadonlyArray) { + const calls: Array = []; + let responseIndex = 0; + const fetchFn = ((input, init) => { + calls.push([input, init ?? {}]); + const response = responses[responseIndex++]; + return response === undefined + ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`)) + : Promise.resolve(response); + }) satisfies typeof fetch; + return { calls, fetchFn }; +} + +const websocketTicket = (ticket: string) => + Response.json({ + ticket, + expiresAt: "2026-06-06T01:00:00.000Z", + }); + +const accessToken = (token: string) => + Response.json({ + access_token: token, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 3_600, + scope: AuthStandardClientScopes.join(" "), + }); + +const authInvalid = () => + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-auth-invalid", + }, + { status: 401 }, + ); + +const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { + readonly initialToken?: TokenStore.RemoteDpopAccessToken; + readonly responses: ReadonlyArray; +}) { + const tokens = yield* Ref.make( + new Map( + input.initialToken === undefined + ? [] + : [[input.initialToken.environmentId, input.initialToken]], + ), + ); + const bootstrapCalls = yield* Ref.make(0); + const proofInputs = yield* Ref.make< + ReadonlyArray<{ + readonly method: string; + readonly url: string; + readonly accessToken?: string; + }> + >([]); + const fetch = recordedFetch(input.responses); + + const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + Ref.get(tokens).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + ), + put: (token) => + Ref.update(tokens, (current) => { + const next = new Map(current); + next.set(token.environmentId, token); + return next; + }), + remove: (environmentId) => + Ref.update(tokens, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }), + }); + const signer = ManagedRelay.ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("thumbprint-1"), + createProof: (proofInput) => + Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( + Effect.as(`proof:${proofInput.url}`), + ), + }); + const layer = RemoteEnvironmentAuthorization.layer.pipe( + Layer.provide( + Layer.mergeAll( + remoteHttpClientLayer(fetch.fetchFn), + Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, signer), + Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed( + ClientCapabilities.ClientPresentation, + ClientCapabilities.ClientPresentation.of({ + metadata: { + label: "T3 Code Test", + deviceType: "mobile", + os: "test", + }, + scopes: AuthStandardClientScopes, + }), + ), + ), + ), + ); + const obtainBootstrap = Ref.update(bootstrapCalls, (count) => count + 1).pipe( + Effect.as(BOOTSTRAP), + ); + + return { + layer, + tokens, + bootstrapCalls, + proofInputs, + fetch, + obtainBootstrap, + }; +}); + +describe("RemoteEnvironmentAuthorization", () => { + it.effect("reuses a valid persisted environment token without contacting the relay", () => + Effect.gen(function* () { + const cached = new TokenStore.RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "cached-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [websocketTicket("cached-ticket")], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=cached-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + expect(harness.fetch.calls).toHaveLength(1); + expect(String(harness.fetch.calls[0]?.[0])).toBe( + "https://environment.example.test/api/auth/websocket-ticket", + ); + }), + ); + + it.effect("refreshes and persists an expired environment token", () => + Effect.gen(function* () { + const expired = new TokenStore.RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "expired-access-token", + expiresAtEpochMs: 0, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: expired, + responses: [ + Response.json(DESCRIPTOR), + accessToken("fresh-access-token"), + websocketTicket("fresh-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=fresh-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "fresh-access-token", + dpopThumbprint: "thumbprint-1", + }), + ); + expect(harness.fetch.calls).toHaveLength(3); + }), + ); + + it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => + Effect.gen(function* () { + const cached = new TokenStore.RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "invalid-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [ + authInvalid(), + Response.json(DESCRIPTOR), + accessToken("replacement-access-token"), + websocketTicket("replacement-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=replacement-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "replacement-access-token", + }), + ); + expect(harness.fetch.calls).toHaveLength(4); + }), + ); + + it.effect("refreshes a cached endpoint after consecutive transient failures", () => + Effect.gen(function* () { + const cached = new TokenStore.RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "cached-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [ + new Response("endpoint unavailable", { status: 503 }), + new Response("endpoint still unavailable", { status: 503 }), + Response.json(DESCRIPTOR), + accessToken("replacement-access-token"), + websocketTicket("replacement-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + const firstFailure = yield* remote + .authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }) + .pipe(Effect.flip); + + expect(firstFailure._tag).toBe("ConnectionTransientError"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toBe(cached); + + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=replacement-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "replacement-access-token", + }), + ); + expect(harness.fetch.calls).toHaveLength(5); + }), + ); + + it.effect("does not persist a refreshed token until its websocket ticket succeeds", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + responses: [ + Response.json(DESCRIPTOR), + accessToken("unusable-access-token"), + new Response("endpoint unavailable", { status: 503 }), + ], + }); + + yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer), Effect.flip); + + expect((yield* Ref.get(harness.tokens)).has(ENVIRONMENT_ID)).toBe(false); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect(harness.fetch.calls).toHaveLength(3); + }), + ); +}); diff --git a/packages/client-runtime/src/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts similarity index 96% rename from packages/client-runtime/src/remote.test.ts rename to packages/client-runtime/src/authorization/remote.test.ts index eabbae8968e..6e6ccc86052 100644 --- a/packages/client-runtime/src/remote.test.ts +++ b/packages/client-runtime/src/authorization/remote.test.ts @@ -10,15 +10,15 @@ import { bootstrapRemoteBearerSession, exchangeRemoteDpopAccessToken, fetchRemoteDpopSessionState, - fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, issueRemoteDpopWebSocketTicket, issueRemoteWebSocketTicket, - remoteHttpClientLayer, RemoteEnvironmentAuthInvalidJsonError, RemoteEnvironmentAuthTimeoutError, resolveRemoteWebSocketConnectionUrl, } from "./remote.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); @@ -88,7 +88,7 @@ const expectFetchCall = ( } }; -describe("remote", () => { +describe("remote environment authorization", () => { it.effect("bootstraps bearer auth against a remote backend", () => Effect.gen(function* () { const fetch = recordedFetch( @@ -391,7 +391,7 @@ describe("remote", () => { expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError); expect(error.message).toBe( - "Remote auth endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", + "Remote environment endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", ); }).pipe(Effect.provide(TestClock.layer())), ); @@ -446,7 +446,7 @@ describe("remote", () => { expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError); expect(error.message).toBe( - "Remote auth endpoint returned an invalid response from https://remote.example.com/oauth/token.", + "Remote environment endpoint returned an invalid response from https://remote.example.com/oauth/token.", ); }), ); @@ -469,7 +469,7 @@ describe("remote", () => { bearerToken: "bearer-token", }).pipe(provideRemoteHttp(fetch.fetchFn)); - expect(url).toBe("wss://remote.example.com/?wsTicket=ws-ticket"); + expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); }), ); }); diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts new file mode 100644 index 00000000000..69c157d0e50 --- /dev/null +++ b/packages/client-runtime/src/authorization/remote.ts @@ -0,0 +1,214 @@ +import { + AuthAccessTokenType, + type AuthClientPresentationMetadata, + AuthEnvironmentBootstrapTokenType, + AuthTokenExchangeGrantType, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; +import * as Effect from "effect/Effect"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { + executeEnvironmentHttpRequest, + makeEnvironmentHttpApiClient, + type RemoteEnvironmentRequestError, +} from "../rpc/http.ts"; + +export { + RemoteEnvironmentAuthFetchError, + RemoteEnvironmentAuthInvalidJsonError, + RemoteEnvironmentAuthTimeoutError, + RemoteEnvironmentAuthUndeclaredStatusError, +} from "../rpc/http.ts"; +export type RemoteEnvironmentAuthError = RemoteEnvironmentRequestError; + +const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; + +const clientMetadataTokenExchangeFields = ( + clientMetadata: AuthClientPresentationMetadata | undefined, +) => ({ + ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), + ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), + ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), +}); + +export const exchangeRemoteDpopAccessToken = Effect.fn( + "clientRuntime.authorization.exchangeRemoteDpopAccessToken", +)(function* (input: { + readonly httpBaseUrl: string; + readonly credential: string; + readonly scopes?: ReadonlyArray; + readonly clientMetadata?: AuthClientPresentationMetadata; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const response = yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.token({ + headers: { dpop: input.dpopProof }, + payload: { + grant_type: AuthTokenExchangeGrantType, + subject_token: input.credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), + ...clientMetadataTokenExchangeFields(input.clientMetadata), + }, + }), + ); + return response; +}); + +export const bootstrapRemoteBearerSession = Effect.fn( + "clientRuntime.authorization.bootstrapRemoteBearerSession", +)(function* (input: { + readonly httpBaseUrl: string; + readonly credential: string; + readonly scopes?: ReadonlyArray; + readonly clientMetadata?: AuthClientPresentationMetadata; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.token({ + headers: {}, + payload: { + grant_type: AuthTokenExchangeGrantType, + subject_token: input.credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), + ...clientMetadataTokenExchangeFields(input.clientMetadata), + }, + }), + ); +}); + +export const fetchRemoteSessionState = Effect.fn( + "clientRuntime.authorization.fetchRemoteSessionState", +)(function* (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.session({ + headers: { + authorization: `Bearer ${input.bearerToken}`, + }, + }), + ); +}); + +export const fetchRemoteDpopSessionState = Effect.fn( + "clientRuntime.authorization.fetchRemoteDpopSessionState", +)(function* (input: { + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.session({ + headers: { + authorization: `DPoP ${input.accessToken}`, + dpop: input.dpopProof, + }, + }), + ); +}); + +export const issueRemoteWebSocketTicket = Effect.fn( + "clientRuntime.authorization.issueRemoteWebSocketTicket", +)(function* (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.webSocketTicket({ + headers: { + authorization: `Bearer ${input.bearerToken}`, + }, + }), + ); +}); + +export const issueRemoteDpopWebSocketTicket = Effect.fn( + "clientRuntime.authorization.issueRemoteDpopWebSocketTicket", +)(function* (input: { + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.webSocketTicket({ + headers: { + authorization: `DPoP ${input.accessToken}`, + dpop: input.dpopProof, + }, + }), + ); +}); + +export const resolveRemoteWebSocketConnectionUrl = Effect.fn( + "clientRuntime.authorization.resolveRemoteWebSocketConnectionUrl", +)(function* (input: { + readonly wsBaseUrl: string; + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const issued = yield* issueRemoteWebSocketTicket({ + httpBaseUrl: input.httpBaseUrl, + bearerToken: input.bearerToken, + ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), + }); + + const url = new URL(input.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + url.searchParams.set("wsTicket", issued.ticket); + return url.toString(); +}); + +export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( + "clientRuntime.authorization.resolveRemoteDpopWebSocketConnectionUrl", +)(function* (input: { + readonly wsBaseUrl: string; + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const issued = yield* issueRemoteDpopWebSocketTicket({ + httpBaseUrl: input.httpBaseUrl, + accessToken: input.accessToken, + dpopProof: input.dpopProof, + ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), + }); + const url = new URL(input.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + url.searchParams.set("wsTicket", issued.ticket); + return url.toString(); +}); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts new file mode 100644 index 00000000000..624ecf7f672 --- /dev/null +++ b/packages/client-runtime/src/authorization/service.ts @@ -0,0 +1,302 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import { + exchangeRemoteDpopAccessToken, + type RemoteEnvironmentAuthError, + resolveRemoteDpopWebSocketConnectionUrl, + resolveRemoteWebSocketConnectionUrl, +} from "./remote.ts"; +import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as TokenStore from "./tokenStore.ts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import type { PreparedHttpAuthorization } from "../connection/model.ts"; + +export interface RelayEnvironmentAuthorization { + readonly environmentId: EnvironmentId; + readonly endpoint: RelayManagedEndpoint; + readonly credential: string; +} + +export interface AuthorizedRemoteEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + readonly socketUrl: string; + readonly httpAuthorization: PreparedHttpAuthorization; +} + +export class RemoteEnvironmentAuthorization extends Context.Service< + RemoteEnvironmentAuthorization, + { + readonly authorizeBearer: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; + readonly authorizeDpop: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly obtainBootstrap: Effect.Effect< + RelayEnvironmentAuthorization, + ConnectionAttemptError + >; + }) => Effect.Effect; + } +>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} + +const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; +const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; + +function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { + return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" + ? error + : mapRemoteEnvironmentError(error); +} + +const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( + httpBaseUrl: string, +) { + return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + ); +}); + +export const make = Effect.gen(function* () { + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; + const presentation = yield* ClientCapabilities.ClientPresentation; + const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; + const httpClient = yield* HttpClient.HttpClient; + const cachedEndpointFailures = yield* Ref.make>(new Map()); + + const resetCachedEndpointFailures = (environmentId: string) => + Ref.update(cachedEndpointFailures, (current) => { + if (!current.has(environmentId)) { + return current; + } + const next = new Map(current); + next.delete(environmentId); + return next; + }); + + const recordCachedEndpointFailure = (environmentId: string) => + Ref.modify(cachedEndpointFailures, (current) => { + const failureCount = (current.get(environmentId) ?? 0) + 1; + const next = new Map(current); + next.set(environmentId, failureCount); + return [failureCount, next] as const; + }); + + const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] + >[0]["expectedEnvironmentId"]; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + }) { + const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: input.wsBaseUrl, + httpBaseUrl: input.httpBaseUrl, + bearerToken: input.bearerToken, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }; + }, + ); + + const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( + function* (token: TokenStore.RemoteDpopAccessToken) { + const ticketProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the websocket authorization proof.", + }), + ), + ); + return yield* resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: token.endpoint.wsBaseUrl, + httpBaseUrl: token.endpoint.httpBaseUrl, + accessToken: token.accessToken, + dpopProof: ticketProof, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + }, + ); + + const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["expectedEnvironmentId"]; + readonly obtainBootstrap: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["obtainBootstrap"]; + }) { + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not load the environment authorization key.", + }), + ), + Effect.withSpan("environment.authorization.dpopKey.resolve"), + ); + const now = yield* Clock.currentTimeMillis; + const cached = yield* tokenStore + .get(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); + if ( + Option.isSome(cached) && + cached.value.environmentId === input.expectedEnvironmentId && + cached.value.dpopThumbprint === thumbprint && + cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS + ) { + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "hit", + }); + const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); + if (Result.isSuccess(cachedSocket)) { + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + return { + environmentId: cached.value.environmentId, + label: cached.value.label, + httpBaseUrl: cached.value.endpoint.httpBaseUrl, + socketUrl: cachedSocket.success, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: cached.value.accessToken, + }, + }; + } + if (cachedSocket.failure._tag === "ConnectionBlockedError") { + return yield* mapDpopSocketError(cachedSocket.failure); + } + const mappedFailure = mapDpopSocketError(cachedSocket.failure); + if (mappedFailure._tag === "ConnectionTransientError") { + const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); + if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { + return yield* mappedFailure; + } + } + yield* tokenStore + .remove(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + } + + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "miss", + }); + const bootstrap = yield* input.obtainBootstrap; + const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.descriptor"), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const bootstrapProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the environment authorization proof.", + }), + ), + ); + const access = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + credential: bootstrap.credential, + dpopProof: bootstrapProof, + scopes: presentation.scopes, + clientMetadata: presentation.metadata, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.accessToken.exchange"), + ); + const issuedAt = yield* Clock.currentTimeMillis; + const token = new TokenStore.RemoteDpopAccessToken({ + environmentId: descriptor.environmentId, + label: descriptor.label, + endpoint: bootstrap.endpoint, + accessToken: access.access_token, + expiresAtEpochMs: issuedAt + access.expires_in * 1_000, + dpopThumbprint: thumbprint, + }); + const socketUrl = yield* createDpopSocketUrl(token).pipe(Effect.mapError(mapDpopSocketError)); + yield* tokenStore + .put(token) + .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: token.accessToken, + }, + }; + }, + ); + + return RemoteEnvironmentAuthorization.of({ + authorizeBearer, + authorizeDpop: (input) => + authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), + }); +}); + +export const layer = Layer.effect(RemoteEnvironmentAuthorization, make); diff --git a/packages/client-runtime/src/authorization/tokenStore.ts b/packages/client-runtime/src/authorization/tokenStore.ts new file mode 100644 index 00000000000..c490a22da13 --- /dev/null +++ b/packages/client-runtime/src/authorization/tokenStore.ts @@ -0,0 +1,37 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ConnectionAttemptError } from "../connection/model.ts"; + +export class RemoteDpopAccessToken extends Schema.Class( + "@t3tools/client-runtime/authorization/RemoteDpopAccessToken", +)({ + environmentId: EnvironmentId, + label: Schema.String, + endpoint: RelayManagedEndpoint, + accessToken: Schema.String, + expiresAtEpochMs: Schema.Number, + dpopThumbprint: Schema.String, +}) {} + +export class RemoteDpopAccessTokenStore extends Context.Service< + RemoteDpopAccessTokenStore, + { + readonly get: ( + environmentId: EnvironmentId, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: (token: RemoteDpopAccessToken) => Effect.Effect; + readonly remove: (environmentId: EnvironmentId) => Effect.Effect; + } +>()("@t3tools/client-runtime/authorization/tokenStore/RemoteDpopAccessTokenStore") {} + +export const make = (service: RemoteDpopAccessTokenStore["Service"]) => + RemoteDpopAccessTokenStore.of(service); + +export const layer = (service: RemoteDpopAccessTokenStore["Service"]) => + Layer.succeed(RemoteDpopAccessTokenStore, make(service)); diff --git a/packages/client-runtime/src/checkpointDiffState.test.ts b/packages/client-runtime/src/checkpointDiffState.test.ts deleted file mode 100644 index c5fa51e3d36..00000000000 --- a/packages/client-runtime/src/checkpointDiffState.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { EnvironmentId, ThreadId, type OrchestrationGetTurnDiffResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type CheckpointDiffClient, - createCheckpointDiffManager, - EMPTY_CHECKPOINT_DIFF_STATE, - getCheckpointDiffTargetKey, -} from "./checkpointDiffState.ts"; - -let registry = AtomRegistry.make(); - -function resetAtomRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), - fromTurnCount: 1, - toTurnCount: 2, - ignoreWhitespace: false, -}; - -const PATCH_RESULT: OrchestrationGetTurnDiffResult = { - threadId: TARGET.threadId, - diff: "patch", - fromTurnCount: 1, - toTurnCount: 2, -}; - -function createClient() { - return { - getTurnDiff: vi.fn(async () => PATCH_RESULT), - getFullThreadDiff: vi.fn(async () => PATCH_RESULT), - } satisfies CheckpointDiffClient; -} - -describe("createCheckpointDiffManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads a turn checkpoint diff into atom state", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); - - expect(client.getTurnDiff).toHaveBeenCalledWith({ - threadId: TARGET.threadId, - fromTurnCount: 1, - toTurnCount: 2, - ignoreWhitespace: false, - }); - expect(client.getFullThreadDiff).not.toHaveBeenCalled(); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: PATCH_RESULT, - error: null, - isPending: false, - }); - }); - - it("loads a full thread diff when the range starts at zero", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - await manager.load({ ...TARGET, fromTurnCount: 0 }); - - expect(client.getFullThreadDiff).toHaveBeenCalledWith({ - threadId: TARGET.threadId, - toTurnCount: 2, - ignoreWhitespace: false, - }); - expect(client.getTurnDiff).not.toHaveBeenCalled(); - }); - - it("returns empty state for invalid targets", () => { - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => createClient(), - }); - - expect(manager.getSnapshot({ ...TARGET, threadId: null })).toBe(EMPTY_CHECKPOINT_DIFF_STATE); - expect(getCheckpointDiffTargetKey({ ...TARGET, threadId: null })).toBeNull(); - }); - - it("deduplicates in-flight requests and reuses successful cached data", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - const first = manager.load(TARGET); - const second = manager.load(TARGET); - - expect(first).toBe(second); - await first; - await manager.load(TARGET); - - expect(client.getTurnDiff).toHaveBeenCalledTimes(1); - }); - - it("retries temporarily unavailable checkpoint diffs", async () => { - let attempts = 0; - const client = { - getFullThreadDiff: vi.fn(async () => PATCH_RESULT), - getTurnDiff: vi.fn(async () => { - attempts += 1; - if (attempts < 3) { - throw new Error("checkpoint is unavailable for turn"); - } - return PATCH_RESULT; - }), - } satisfies CheckpointDiffClient; - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - retryDelay: async () => undefined, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); - - expect(client.getTurnDiff).toHaveBeenCalledTimes(3); - }); -}); diff --git a/packages/client-runtime/src/checkpointDiffState.ts b/packages/client-runtime/src/checkpointDiffState.ts deleted file mode 100644 index b0752584bc6..00000000000 --- a/packages/client-runtime/src/checkpointDiffState.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - type EnvironmentId, - OrchestrationGetFullThreadDiffInput, - type OrchestrationGetFullThreadDiffResult, - OrchestrationGetTurnDiffInput, - type OrchestrationGetTurnDiffResult, - type ThreadId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type CheckpointDiffResult = - | OrchestrationGetTurnDiffResult - | OrchestrationGetFullThreadDiffResult; - -export interface CheckpointDiffState { - readonly data: CheckpointDiffResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface CheckpointDiffTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; - readonly fromTurnCount: number | null; - readonly toTurnCount: number | null; - readonly ignoreWhitespace: boolean; - readonly cacheScope?: string | null; -} - -export interface CheckpointDiffClient { - readonly getTurnDiff: ( - input: OrchestrationGetTurnDiffInput, - ) => Promise; - readonly getFullThreadDiff: ( - input: OrchestrationGetFullThreadDiffInput, - ) => Promise; -} - -export const EMPTY_CHECKPOINT_DIFF_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_CHECKPOINT_DIFF_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownCheckpointDiffKeys = new Set(); - -export const checkpointDiffStateAtom = Atom.family((key: string) => { - knownCheckpointDiffKeys.add(key); - return Atom.make(INITIAL_CHECKPOINT_DIFF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`checkpoint-diff:${key}`), - ); -}); - -export const EMPTY_CHECKPOINT_DIFF_ATOM = Atom.make(EMPTY_CHECKPOINT_DIFF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("checkpoint-diff:null"), -); - -const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput); -const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput); - -type CheckpointDiffRequest = - | { - readonly kind: "fullThreadDiff"; - readonly input: OrchestrationGetFullThreadDiffInput; - } - | { - readonly kind: "turnDiff"; - readonly input: OrchestrationGetTurnDiffInput; - }; - -export function getCheckpointDiffTargetKey(target: CheckpointDiffTarget): string | null { - const decoded = decodeCheckpointDiffRequest(target); - if (target.environmentId === null || decoded._tag === "None") { - return null; - } - - return [ - target.environmentId, - target.threadId, - target.fromTurnCount, - target.toTurnCount, - target.ignoreWhitespace, - target.cacheScope ?? null, - ].join(":"); -} - -function decodeCheckpointDiffRequest(target: CheckpointDiffTarget) { - if (target.fromTurnCount === 0) { - return decodeFullThreadDiffInput({ - threadId: target.threadId, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - }).pipe(Option.map((input) => ({ kind: "fullThreadDiff" as const, input }))); - } - - return decodeTurnDiffInput({ - threadId: target.threadId, - fromTurnCount: target.fromTurnCount, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - }).pipe(Option.map((input) => ({ kind: "turnDiff" as const, input }))); -} - -function asCheckpointErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "string") { - return error; - } - return ""; -} - -export function normalizeCheckpointDiffErrorMessage(error: unknown): string { - const message = asCheckpointErrorMessage(error).trim(); - if (message.length === 0) { - return "Failed to load checkpoint diff."; - } - - const lower = message.toLowerCase(); - if (lower.includes("not a git repository")) { - return "Turn diffs are unavailable because this project is not a git repository."; - } - - if ( - lower.includes("checkpoint unavailable for thread") || - lower.includes("checkpoint invariant violation") - ) { - const separatorIndex = message.indexOf(":"); - if (separatorIndex >= 0) { - const detail = message.slice(separatorIndex + 1).trim(); - if (detail.length > 0) { - return detail; - } - } - } - - return message; -} - -function isCheckpointTemporarilyUnavailable(error: unknown): boolean { - const message = asCheckpointErrorMessage(error).toLowerCase(); - return ( - message.includes("exceeds current turn count") || - message.includes("checkpoint is unavailable for turn") || - message.includes("filesystem checkpoint is unavailable") - ); -} - -function defaultRetryDelay(attempt: number, error: unknown): Promise { - const delayMs = isCheckpointTemporarilyUnavailable(error) - ? Math.min(5_000, 250 * 2 ** (attempt - 1)) - : Math.min(1_000, 100 * 2 ** (attempt - 1)); - return Effect.runPromise(Effect.sleep(Duration.millis(delayMs))); -} - -export function createCheckpointDiffManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => CheckpointDiffClient | null; - readonly retryDelay?: (attempt: number, error: unknown) => Promise; -}) { - const inFlight = new Map>(); - const versions = new Map(); - - function getVersion(targetKey: string): number { - return versions.get(targetKey) ?? 0; - } - - function bumpVersion(targetKey: string): void { - versions.set(targetKey, getVersion(targetKey) + 1); - } - - function setState(targetKey: string, state: CheckpointDiffState): void { - config.getRegistry().set(checkpointDiffStateAtom(targetKey), state); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - setState( - targetKey, - current.data === null ? INITIAL_CHECKPOINT_DIFF_STATE : { ...current, isPending: true }, - ); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: normalizeCheckpointDiffErrorMessage(error), - isPending: false, - }); - } - - async function requestWithRetry( - client: CheckpointDiffClient, - request: CheckpointDiffRequest, - ): Promise { - let attempt = 0; - while (true) { - attempt += 1; - try { - if (request.kind === "fullThreadDiff") { - return await client.getFullThreadDiff(request.input); - } - return await client.getTurnDiff(request.input); - } catch (error) { - const maxAttempts = isCheckpointTemporarilyUnavailable(error) ? 13 : 4; - if (attempt >= maxAttempts) { - throw error; - } - await (config.retryDelay ?? defaultRetryDelay)(attempt, error); - } - } - } - - function load( - target: CheckpointDiffTarget, - client?: CheckpointDiffClient, - options?: { readonly force?: boolean }, - ): Promise { - const targetKey = getCheckpointDiffTargetKey(target); - const decoded = decodeCheckpointDiffRequest(target); - if (targetKey === null || target.environmentId === null || decoded._tag === "None") { - return Promise.resolve(null); - } - - if (!options?.force) { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - if (current.data !== null && current.error === null) { - return Promise.resolve(current.data); - } - } - - const existing = inFlight.get(targetKey); - if (existing) { - return existing; - } - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - setError(targetKey, new Error("Remote connection is not ready.")); - return Promise.resolve(config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data); - } - - markPending(targetKey); - const version = getVersion(targetKey); - const promise = requestWithRetry(resolved, decoded.value).then( - (result) => { - if (getVersion(targetKey) === version) { - setState(targetKey, { data: result, error: null, isPending: false }); - } - return result; - }, - (error: unknown) => { - if (getVersion(targetKey) === version) { - setError(targetKey, error); - } - return config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data; - }, - ); - inFlight.set(targetKey, promise); - void promise.finally(() => { - if (inFlight.get(targetKey) === promise) { - inFlight.delete(targetKey); - } - }); - return promise; - } - - function getSnapshot(target: CheckpointDiffTarget): CheckpointDiffState { - const targetKey = getCheckpointDiffTargetKey(target); - return targetKey === null - ? EMPTY_CHECKPOINT_DIFF_STATE - : config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - } - - function invalidate(target?: CheckpointDiffTarget): void { - if (target) { - const targetKey = getCheckpointDiffTargetKey(target); - if (targetKey === null) { - return; - } - bumpVersion(targetKey); - inFlight.delete(targetKey); - setState(targetKey, INITIAL_CHECKPOINT_DIFF_STATE); - return; - } - - for (const key of knownCheckpointDiffKeys) { - bumpVersion(key); - setState(key, INITIAL_CHECKPOINT_DIFF_STATE); - } - inFlight.clear(); - } - - return { - getSnapshot, - invalidate, - load, - }; -} diff --git a/packages/client-runtime/src/composerPathSearchState.test.ts b/packages/client-runtime/src/composerPathSearchState.test.ts deleted file mode 100644 index 8e5c739ba5d..00000000000 --- a/packages/client-runtime/src/composerPathSearchState.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { assert, beforeEach, it, vi } from "vite-plus/test"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - type ComposerPathSearchClient, - createComposerPathSearchManager, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - getComposerPathSearchTargetKey, -} from "./composerPathSearchState.ts"; - -let registry = AtomRegistry.make(); - -const noop = () => undefined; - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -const TARGET = { - environmentId: "env-local" as EnvironmentId, - cwd: "/repo", - query: "src", -}; - -it("derives null keys for inactive path searches", () => { - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, query: "" }), null); - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, cwd: null }), null); - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, environmentId: null }), null); -}); - -it("stores path search results in atom state", async () => { - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - getClient: () => ({ - searchEntries: async () => ({ - entries: [ - { path: "src/index.ts", kind: "file" }, - { path: "src/components", kind: "directory" }, - ], - truncated: false, - }), - }), - }); - - manager.search(TARGET); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [ - { path: "src/index.ts", kind: "file" }, - { path: "src/components", kind: "directory" }, - ], - isPending: false, - error: null, - }); -}); - -it("reuses fresh cached path search results", async () => { - const searchEntries = vi.fn(async () => ({ - entries: [{ path: "src/index.ts", kind: "file" as const }], - truncated: false, - })); - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - staleTimeMs: 15_000, - getClient: () => ({ searchEntries }), - }); - - manager.search(TARGET); - await flushAsyncWork(); - manager.search(TARGET); - await flushAsyncWork(); - - assert.strictEqual(searchEntries.mock.calls.length, 1); - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/index.ts", kind: "file" }], - isPending: false, - error: null, - }); -}); - -it("invalidates watched path searches and refreshes without clearing entries", async () => { - type SearchResult = Awaited>; - - let resolveSecond: (value: SearchResult) => void = noop; - let callCount = 0; - const searchEntries = vi.fn((() => { - callCount += 1; - if (callCount === 1) { - return Promise.resolve({ - entries: [{ path: "src/old.ts", kind: "file" as const }], - truncated: false, - }); - } - return new Promise((resolve) => { - resolveSecond = resolve; - }); - }) satisfies ComposerPathSearchClient["searchEntries"]); - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - staleTimeMs: 15_000, - getClient: () => ({ searchEntries }), - }); - - const unwatch = manager.watch(TARGET); - await flushAsyncWork(); - manager.invalidate(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/old.ts", kind: "file" }], - isPending: true, - error: null, - }); - - resolveSecond({ - entries: [{ path: "src/new.ts", kind: "file" }], - truncated: false, - }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/new.ts", kind: "file" }], - isPending: false, - error: null, - }); - assert.strictEqual(searchEntries.mock.calls.length, 2); - unwatch(); -}); - -it("ignores stale path search results after a newer request starts", async () => { - let resolveFirst: (value: { - entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>; - truncated: boolean; - }) => void = noop; - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - getClient: () => ({ - searchEntries: (input: Parameters[0]) => { - if (input.query === "first") { - return new Promise((resolve) => { - resolveFirst = resolve; - }); - } - return Promise.resolve({ - entries: [{ path: "second.ts", kind: "file" }], - truncated: false, - }); - }, - }), - }); - - manager.search({ ...TARGET, query: "first" }); - manager.search({ ...TARGET, query: "second" }); - await flushAsyncWork(); - resolveFirst({ entries: [{ path: "first.ts", kind: "file" }], truncated: false }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot({ ...TARGET, query: "second" }), { - entries: [{ path: "second.ts", kind: "file" }], - isPending: false, - error: null, - }); -}); - -it("returns the empty snapshot for inactive targets", () => { - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - getClient: () => null, - }); - - assert.deepStrictEqual( - manager.getSnapshot({ environmentId: null, cwd: null, query: null }), - EMPTY_COMPOSER_PATH_SEARCH_STATE, - ); -}); diff --git a/packages/client-runtime/src/composerPathSearchState.ts b/packages/client-runtime/src/composerPathSearchState.ts deleted file mode 100644 index 693d60cb46f..00000000000 --- a/packages/client-runtime/src/composerPathSearchState.ts +++ /dev/null @@ -1,341 +0,0 @@ -import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface ComposerPathSearchEntry { - readonly path: string; - readonly kind: "file" | "directory"; -} - -export interface ComposerPathSearchState { - readonly entries: ReadonlyArray; - readonly isPending: boolean; - readonly error: string | null; -} - -export interface ComposerPathSearchTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly query: string | null; -} - -export interface ComposerPathSearchClient { - readonly searchEntries: (input: { - readonly cwd: string; - readonly query: string; - readonly limit: number; - }) => Promise; -} - -interface WatchedEntry { - refCount: number; - target: ComposerPathSearchTarget & { - readonly environmentId: EnvironmentId; - readonly cwd: string; - }; - teardown: () => void; -} - -export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze({ - entries: [], - isPending: false, - error: null, -}); - -const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze({ - entries: [], - isPending: true, - error: null, -}); - -const NOOP: () => void = () => undefined; -const DEFAULT_DEBOUNCE_MS = 200; -const DEFAULT_LIMIT = 20; - -export const composerPathSearchStateAtom = Atom.family((key: string) => - Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`composer-path-search:${key}`), - ), -); - -export const EMPTY_COMPOSER_PATH_SEARCH_ATOM = Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("composer-path-search:null"), -); - -export function normalizeComposerPathSearchQuery(query: string | null): string { - return query?.trim() ?? ""; -} - -export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget): string | null { - const query = normalizeComposerPathSearchQuery(target.query); - if (target.environmentId === null || target.cwd === null || query.length === 0) { - return null; - } - - return `${target.environmentId}:${target.cwd}:${query}`; -} - -function toSearchEntries( - entries: ProjectSearchEntriesResult["entries"], -): ReadonlyArray { - return entries; -} - -export function createComposerPathSearchManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ComposerPathSearchClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly debounceMs?: number; - readonly limit?: number; - readonly staleTimeMs?: number; -}) { - const watched = new Map(); - const versions = new Map(); - const timers = new Map>(); - const lastLoadedAt = new Map(); - const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; - const limit = config.limit ?? DEFAULT_LIMIT; - - function bumpVersion(targetKey: string): number { - const next = (versions.get(targetKey) ?? 0) + 1; - versions.set(targetKey, next); - return next; - } - - function setState(targetKey: string, state: ComposerPathSearchState): void { - config.getRegistry().set(composerPathSearchStateAtom(targetKey), state); - } - - function clearTimer(targetKey: string): void { - const fiber = timers.get(targetKey); - if (fiber) { - Effect.runFork(Fiber.interrupt(fiber)); - timers.delete(targetKey); - } - } - - function getSnapshot(target: ComposerPathSearchTarget): ComposerPathSearchState { - const targetKey = getComposerPathSearchTargetKey(target); - return targetKey === null - ? EMPTY_COMPOSER_PATH_SEARCH_STATE - : config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - } - - function runSearch( - targetKey: string, - target: ComposerPathSearchTarget & { - readonly environmentId: EnvironmentId; - readonly cwd: string; - }, - client: ComposerPathSearchClient, - version: number, - ): void { - void client - .searchEntries({ - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - limit, - }) - .then((result) => { - if (versions.get(targetKey) !== version) { - return; - } - setState(targetKey, { - entries: toSearchEntries(result.entries), - isPending: false, - error: null, - }); - lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); - }) - .catch((error: unknown) => { - if (versions.get(targetKey) !== version) { - return; - } - const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - setState(targetKey, { - entries: current.entries, - isPending: false, - error: error instanceof Error ? error.message : "Failed to search project files.", - }); - }); - } - - function search( - target: ComposerPathSearchTarget, - client?: ComposerPathSearchClient, - options?: { readonly force?: boolean }, - ): void { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return; - } - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - setState(targetKey, { - entries: [], - isPending: false, - error: "Remote connection is not ready.", - }); - return; - } - - const lastLoaded = lastLoadedAt.get(targetKey); - if ( - !options?.force && - lastLoaded !== undefined && - config.staleTimeMs !== undefined && - Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs - ) { - return; - } - - const version = bumpVersion(targetKey); - clearTimer(targetKey); - const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - setState( - targetKey, - current.entries.length === 0 - ? PENDING_COMPOSER_PATH_SEARCH_STATE - : { ...current, isPending: true, error: null }, - ); - - const readyTarget = { - ...target, - environmentId: target.environmentId, - cwd: target.cwd, - }; - - if (debounceMs <= 0) { - runSearch(targetKey, readyTarget, resolved, version); - return; - } - - const fiber = Effect.runFork( - Effect.sleep(Duration.millis(debounceMs)).pipe( - Effect.andThen( - Effect.sync(() => { - timers.delete(targetKey); - runSearch(targetKey, readyTarget, resolved, version); - }), - ), - ), - ); - timers.set(targetKey, fiber); - } - - function watch(target: ComposerPathSearchTarget): () => void { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const readyTarget = { - ...target, - environmentId: target.environmentId, - cwd: target.cwd, - }; - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let currentClient: ComposerPathSearchClient | null = null; - const sync = () => { - const client = config.getClient(target.environmentId!); - if (!client) { - currentClient = null; - setState(targetKey, { - entries: [], - isPending: false, - error: "Remote connection is not ready.", - }); - return; - } - - if (currentClient === client) { - return; - } - - currentClient = client; - search(readyTarget, client); - }; - - const unsubscribe = config.subscribeClientChanges?.(sync) ?? NOOP; - sync(); - - watched.set(targetKey, { - refCount: 1, - target: readyTarget, - teardown: () => { - unsubscribe(); - clearTimer(targetKey); - bumpVersion(targetKey); - }, - }); - - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - versions.clear(); - for (const targetKey of timers.keys()) { - clearTimer(targetKey); - } - lastLoadedAt.clear(); - } - - function invalidate(target?: ComposerPathSearchTarget): void { - if (target) { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null) { - return; - } - lastLoadedAt.delete(targetKey); - const watchedEntry = watched.get(targetKey); - if (watchedEntry) { - search(watchedEntry.target, undefined, { force: true }); - } - return; - } - - lastLoadedAt.clear(); - for (const watchedEntry of watched.values()) { - search(watchedEntry.target, undefined, { force: true }); - } - } - - return { - invalidate, - getSnapshot, - search, - watch, - reset, - }; -} diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts new file mode 100644 index 00000000000..84f81153194 --- /dev/null +++ b/packages/client-runtime/src/connection/catalog.ts @@ -0,0 +1,115 @@ +import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, +} from "./model.ts"; + +const ConnectionProfileBase = { + connectionId: Schema.String, + environmentId: EnvironmentId, + label: Schema.String, +}; + +export class BearerConnectionProfile extends Schema.TaggedClass()( + "BearerConnectionProfile", + { + ...ConnectionProfileBase, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + }, +) {} + +export class SshConnectionProfile extends Schema.TaggedClass()( + "SshConnectionProfile", + { + ...ConnectionProfileBase, + target: DesktopSshEnvironmentTargetSchema, + }, +) {} + +export const ConnectionProfile = Schema.Union([BearerConnectionProfile, SshConnectionProfile]); +export type ConnectionProfile = typeof ConnectionProfile.Type; + +export interface ConnectionCatalogEntry { + readonly target: ConnectionTarget; + readonly profile: Option.Option; +} + +export class BearerConnectionCredential extends Schema.TaggedClass()( + "BearerConnectionCredential", + { + token: Schema.String, + }, +) {} + +export const ConnectionCredential = Schema.Union([BearerConnectionCredential]); +export type ConnectionCredential = typeof ConnectionCredential.Type; + +export class PrimaryConnectionRegistration extends Schema.TaggedClass()( + "PrimaryConnectionRegistration", + { + target: PrimaryConnectionTarget, + }, +) {} + +export class RelayConnectionRegistration extends Schema.TaggedClass()( + "RelayConnectionRegistration", + { + target: RelayConnectionTarget, + }, +) {} + +export class BearerConnectionRegistration extends Schema.TaggedClass()( + "BearerConnectionRegistration", + { + target: BearerConnectionTarget, + profile: BearerConnectionProfile, + credential: BearerConnectionCredential, + }, +) {} + +export class SshConnectionRegistration extends Schema.TaggedClass()( + "SshConnectionRegistration", + { + target: SshConnectionTarget, + profile: SshConnectionProfile, + }, +) {} + +export const ConnectionRegistration = Schema.Union([ + RelayConnectionRegistration, + BearerConnectionRegistration, + SshConnectionRegistration, +]); +export type ConnectionRegistration = typeof ConnectionRegistration.Type; + +export function connectionRegistrationTarget( + registration: ConnectionRegistration | PrimaryConnectionRegistration, +): ConnectionTarget { + return registration.target; +} + +export function connectionRegistrationCatalogEntry( + registration: ConnectionRegistration | PrimaryConnectionRegistration, +): ConnectionCatalogEntry { + switch (registration._tag) { + case "PrimaryConnectionRegistration": + case "RelayConnectionRegistration": + return { + target: registration.target, + profile: Option.none(), + }; + case "BearerConnectionRegistration": + case "SshConnectionRegistration": + return { + target: registration.target, + profile: Option.some(registration.profile), + }; + } +} diff --git a/packages/client-runtime/src/connection/connectivity.ts b/packages/client-runtime/src/connection/connectivity.ts new file mode 100644 index 00000000000..6b40680ce35 --- /dev/null +++ b/packages/client-runtime/src/connection/connectivity.ts @@ -0,0 +1,19 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Stream from "effect/Stream"; + +import type { NetworkStatus } from "./model.ts"; + +export class Connectivity extends Context.Service< + Connectivity, + { + readonly status: Effect.Effect; + readonly changes: Stream.Stream; + } +>()("@t3tools/client-runtime/connection/connectivity") {} + +export const make = (service: Connectivity["Service"]) => Connectivity.of(service); + +export const layer = (service: Connectivity["Service"]) => + Layer.succeed(Connectivity, make(service)); diff --git a/packages/client-runtime/src/connection/credentialStore.ts b/packages/client-runtime/src/connection/credentialStore.ts new file mode 100644 index 00000000000..0107bc91fb1 --- /dev/null +++ b/packages/client-runtime/src/connection/credentialStore.ts @@ -0,0 +1,27 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Option from "effect/Option"; + +import type { ConnectionCredential } from "./catalog.ts"; +import type { ConnectionAttemptError } from "./model.ts"; + +export class ConnectionCredentialStore extends Context.Service< + ConnectionCredentialStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: ( + connectionId: string, + credential: ConnectionCredential, + ) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/credentialStore/ConnectionCredentialStore") {} + +export const make = (service: ConnectionCredentialStore["Service"]) => + ConnectionCredentialStore.of(service); + +export const layer = (service: ConnectionCredentialStore["Service"]) => + Layer.succeed(ConnectionCredentialStore, make(service)); diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts new file mode 100644 index 00000000000..f29f913dd54 --- /dev/null +++ b/packages/client-runtime/src/connection/driver.ts @@ -0,0 +1,64 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import type { + ConnectionAttemptError, + ConnectionAttemptStage, + PreparedConnection, +} from "./model.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as RpcSession from "../rpc/session.ts"; + +export type ConnectionDriverProgress = + | { + readonly stage: "preparing"; + } + | { + readonly stage: Exclude; + readonly prepared: PreparedConnection; + }; + +export interface EnvironmentConnectionLease { + readonly prepared: PreparedConnection; + readonly session: RpcSession.RpcSession; +} + +export class ConnectionDriver extends Context.Service< + ConnectionDriver, + { + readonly connect: ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {} + +export const make = Effect.gen(function* () { + const resolver = yield* ConnectionResolver.ConnectionResolver; + const sessions = yield* RpcSession.RpcSessionFactory; + + const connect = Effect.fn("ConnectionDriver.connect")(function* ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) { + const target = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, + }); + yield* reportProgress({ stage: "preparing" }); + const prepared = yield* resolver.prepare(entry); + yield* reportProgress({ stage: "opening", prepared }); + const session = yield* sessions.connect(prepared); + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session } satisfies EnvironmentConnectionLease; + }); + + return ConnectionDriver.of({ connect }); +}); + +export const layer = Layer.effect(ConnectionDriver, make); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts new file mode 100644 index 00000000000..f70e41adfe7 --- /dev/null +++ b/packages/client-runtime/src/connection/errors.ts @@ -0,0 +1,158 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayProtectedError } from "@t3tools/contracts/relay"; +import type { ManagedRelayClientError } from "../relay/managedRelay.ts"; +import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts"; +import { + ConnectionBlockedError, + type ConnectionAttemptError, + ConnectionTransientError, +} from "./model.ts"; + +export function profileMissingError(connectionId: string): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "configuration", + detail: `Connection profile ${connectionId} is unavailable.`, + }); +} + +export function credentialMissingError(connectionId: string): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "authentication", + detail: `Connection credential ${connectionId} is unavailable.`, + }); +} + +export function environmentMismatchError(input: { + readonly expected: EnvironmentId; + readonly actual: EnvironmentId; +}): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "configuration", + detail: `Connected environment ${input.actual} does not match ${input.expected}.`, + }); +} + +function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError { + switch (error._tag) { + case "RelayAuthInvalidError": + case "RelayEnvironmentLinkProofExpiredError": + case "RelayAgentActivityPublishProofExpiredError": + case "RelayAgentActivityPublishProofInvalidError": + return new ConnectionBlockedError({ + reason: "authentication", + detail: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentConnectNotAuthorizedError": + case "RelayEnvironmentLinkProofInvalidError": + return new ConnectionBlockedError({ + reason: "permission", + detail: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentEndpointTimedOutError": + return new ConnectionTransientError({ + reason: "timeout", + detail: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentEndpointUnavailableError": + case "RelayEnvironmentLinkUnavailableError": + return new ConnectionTransientError({ + reason: "endpoint-unavailable", + detail: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentLinkFailedError": + case "RelayInternalError": + return new ConnectionTransientError({ + reason: "relay-unavailable", + detail: error.message, + traceId: error.traceId, + }); + } +} + +export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError { + switch (error._tag) { + case "ManagedRelayRequestFailedError": + if (error.relayError) { + return relayProtectedError(error.relayError); + } + return new ConnectionTransientError({ + reason: "relay-unavailable", + detail: error.message, + ...(error.traceId ? { traceId: error.traceId } : {}), + }); + case "ManagedRelayRequestTimeoutError": + return new ConnectionTransientError({ + reason: "timeout", + detail: error.message, + }); + case "ManagedRelayUrlInvalidError": + return new ConnectionBlockedError({ + reason: "configuration", + detail: error.message, + }); + case "ManagedRelayAccessTokenScopesUnexpectedError": + return new ConnectionBlockedError({ + reason: "permission", + detail: error.message, + }); + case "ManagedRelayDpopKeyLoadError": + case "ManagedRelayTokenProofCreationError": + case "ManagedRelayRequestProofCreationError": + return new ConnectionBlockedError({ + reason: "authentication", + detail: error.message, + }); + } +} + +export function mapRemoteEnvironmentError( + error: RemoteEnvironmentAuthError, +): ConnectionAttemptError { + switch (error._tag) { + case "EnvironmentAuthInvalidError": + return new ConnectionBlockedError({ + reason: "authentication", + detail: "The environment credential is invalid.", + traceId: error.traceId, + }); + case "EnvironmentScopeRequiredError": + case "EnvironmentOperationForbiddenError": + return new ConnectionBlockedError({ + reason: "permission", + detail: "The environment credential does not grant the required access.", + traceId: error.traceId, + }); + case "EnvironmentRequestInvalidError": + return new ConnectionBlockedError({ + reason: "configuration", + detail: "The environment rejected the authentication request.", + traceId: error.traceId, + }); + case "RemoteEnvironmentAuthTimeoutError": + return new ConnectionTransientError({ + reason: "timeout", + detail: error.message, + }); + case "RemoteEnvironmentAuthFetchError": + return new ConnectionTransientError({ + reason: "network", + detail: error.message, + }); + case "EnvironmentInternalError": + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: "The environment could not authorize the connection.", + traceId: error.traceId, + }); + case "RemoteEnvironmentAuthInvalidJsonError": + case "RemoteEnvironmentAuthUndeclaredStatusError": + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: error.message, + }); + } +} diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts new file mode 100644 index 00000000000..53a041bbf30 --- /dev/null +++ b/packages/client-runtime/src/connection/index.ts @@ -0,0 +1,33 @@ +export * from "./catalog.ts"; +export * as Connectivity from "./connectivity.ts"; +export * as CredentialStore from "./credentialStore.ts"; +export { + ConnectionDriver, + type ConnectionDriverProgress, + type EnvironmentConnectionLease, +} from "./driver.ts"; +export * from "./errors.ts"; +export * as Connection from "./layer.ts"; +export * from "./model.ts"; +export { + type BearerConnectionUpdateInput, + ConnectionOnboarding, + type PairingConnectionInput, + type SshConnectionInput, + prepareBearerConnectionUpdate, + preparePairingRegistration, + prepareSshRegistration, + registerPairingConnection, + registerSshConnection, + updateBearerConnection, +} from "./onboarding.ts"; +export * from "./presentation.ts"; +export * as ProfileStore from "./profileStore.ts"; +export { + EnvironmentNotRegisteredError, + EnvironmentRegistry, + PlatformEnvironmentRemovalError, +} from "./registry.ts"; +export { ConnectionResolver } from "./resolver.ts"; +export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts"; +export * as Wakeups from "./wakeups.ts"; diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts new file mode 100644 index 00000000000..a7485878e2d --- /dev/null +++ b/packages/client-runtime/src/connection/layer.ts @@ -0,0 +1,44 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import * as ConnectionResolver from "./resolver.ts"; +import * as ConnectionDriver from "./driver.ts"; +import * as EnvironmentRegistry from "./registry.ts"; +import * as ConnectionOnboarding from "./onboarding.ts"; +import * as PlatformConnectionSource from "../platform/source.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import * as RpcSession from "../rpc/session.ts"; + +const resolverLayer = ConnectionResolver.layer.pipe( + Layer.provide(RemoteEnvironmentAuthorization.layer), +); + +const driverLayer = ConnectionDriver.layer.pipe( + Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), +); + +const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); + +const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); + +const connectionServicesLayer = Layer.mergeAll( + registryLayer, + RelayEnvironmentDiscovery.layer, + onboardingLayer, +); + +const connectionStartupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; + yield* registry.start; + yield* platformSource.registrations.pipe( + Stream.runForEach(registry.registerPlatform), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), +); + +export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts new file mode 100644 index 00000000000..fbcb302ed13 --- /dev/null +++ b/packages/client-runtime/src/connection/model.ts @@ -0,0 +1,173 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const ConnectionTargetBase = { + environmentId: EnvironmentId, + label: Schema.String, +}; + +export class PrimaryConnectionTarget extends Schema.TaggedClass()( + "PrimaryConnectionTarget", + { + ...ConnectionTargetBase, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + }, +) {} + +export class BearerConnectionTarget extends Schema.TaggedClass()( + "BearerConnectionTarget", + { + ...ConnectionTargetBase, + connectionId: Schema.String, + }, +) {} + +export class RelayConnectionTarget extends Schema.TaggedClass()( + "RelayConnectionTarget", + { + ...ConnectionTargetBase, + }, +) {} + +export class SshConnectionTarget extends Schema.TaggedClass()( + "SshConnectionTarget", + { + ...ConnectionTargetBase, + connectionId: Schema.String, + }, +) {} + +export const ConnectionTarget = Schema.Union([ + PrimaryConnectionTarget, + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +]); +export type ConnectionTarget = typeof ConnectionTarget.Type; + +export const PersistedConnectionTarget = Schema.Union([ + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +]); +export type PersistedConnectionTarget = typeof PersistedConnectionTarget.Type; + +export type ConnectionTargetKind = ConnectionTarget["_tag"]; + +export type NetworkStatus = "unknown" | "offline" | "online"; + +export const ConnectionTransientReason = Schema.Literals([ + "network", + "timeout", + "transport", + "endpoint-unavailable", + "relay-unavailable", + "remote-unavailable", +]); +export type ConnectionTransientReason = typeof ConnectionTransientReason.Type; + +export const ConnectionBlockedReason = Schema.Literals([ + "authentication", + "configuration", + "permission", + "unsupported", +]); +export type ConnectionBlockedReason = typeof ConnectionBlockedReason.Type; + +export class ConnectionTransientError extends Schema.TaggedErrorClass()( + "ConnectionTransientError", + { + reason: ConnectionTransientReason, + detail: Schema.String, + traceId: Schema.optionalKey(Schema.String), + }, +) { + override get message(): string { + return this.detail; + } +} + +export class ConnectionBlockedError extends Schema.TaggedErrorClass()( + "ConnectionBlockedError", + { + reason: ConnectionBlockedReason, + detail: Schema.String, + traceId: Schema.optionalKey(Schema.String), + }, +) { + override get message(): string { + return this.detail; + } +} + +export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError; + +export type PreparedHttpAuthorization = + | { + readonly _tag: "Bearer"; + readonly token: string; + } + | { + readonly _tag: "Dpop"; + readonly accessToken: string; + }; + +export interface PreparedConnection { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + readonly socketUrl: string; + readonly httpAuthorization: PreparedHttpAuthorization | null; + readonly target: ConnectionTarget; +} + +export type SupervisorConnectionPhase = + | "available" + | "offline" + | "connecting" + | "backoff" + | "connected" + | "blocked"; + +export type ConnectionAttemptStage = "preparing" | "opening" | "synchronizing"; + +export interface SupervisorConnectionState { + readonly desired: boolean; + readonly network: NetworkStatus; + readonly phase: SupervisorConnectionPhase; + readonly stage: ConnectionAttemptStage | null; + readonly attempt: number; + readonly generation: number; + readonly lastFailure: ConnectionAttemptError | null; + readonly retryAt: number | null; +} + +export type ConnectionProjectionPhase = "disconnected" | "synchronizing" | "ready"; + +export function connectionProjectionPhase( + state: SupervisorConnectionState, +): ConnectionProjectionPhase { + switch (state.phase) { + case "connecting": + return "synchronizing"; + case "connected": + return "ready"; + case "available": + case "offline": + case "backoff": + case "blocked": + return "disconnected"; + } +} + +export const AVAILABLE_CONNECTION_STATE: SupervisorConnectionState = Object.freeze({ + desired: false, + network: "unknown", + phase: "available", + stage: null, + attempt: 0, + generation: 0, + lastFailure: null, + retryAt: null, +}); diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts new file mode 100644 index 00000000000..9bee0dad6fb --- /dev/null +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -0,0 +1,257 @@ +import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; +import { BearerConnectionTarget } from "./model.ts"; +import { + prepareBearerConnectionUpdate, + preparePairingRegistration, + prepareSshRegistration, +} from "./onboarding.ts"; + +const CLIENT_PRESENTATION_LAYER = Layer.succeed( + ClientPresentation, + ClientPresentation.of({ + metadata: { + label: "T3 Code Test", + deviceType: "desktop", + os: "Test OS", + }, + scopes: AuthStandardClientScopes, + }), +); + +function pairingHttpLayer( + calls: Array<{ readonly url: string; readonly init: RequestInit }>, + options?: { readonly failDescriptor?: boolean }, +) { + const fetchFn = ((input, init = {}) => { + const url = String(input); + calls.push({ url, init }); + + if (url.endsWith("/.well-known/t3/environment")) { + if (options?.failDescriptor === true) { + return Promise.resolve( + Response.json({ message: "descriptor unavailable" }, { status: 503 }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "environment-paired", + label: "Paired environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }), + ); + } + + if (url.endsWith("/oauth/token")) { + return Promise.resolve( + Response.json({ + access_token: "bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: AuthStandardClientScopes.join(" "), + }), + ); + } + + return Promise.reject(new Error(`Unexpected request: ${url}`)); + }) satisfies typeof fetch; + + return remoteHttpClientLayer(fetchFn); +} + +describe("connection onboarding", () => { + it.effect("prepares a persisted bearer registration from pairing details", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const registration = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe(Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls)))); + + expect(registration).toMatchObject({ + _tag: "BearerConnectionRegistration", + target: { + environmentId: "environment-paired", + label: "Paired environment", + connectionId: "bearer:environment-paired", + }, + profile: { + environmentId: "environment-paired", + label: "Paired environment", + connectionId: "bearer:environment-paired", + httpBaseUrl: "https://remote.example.test/", + wsBaseUrl: "wss://remote.example.test/", + }, + credential: { + token: "bearer-token", + }, + }); + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + "https://remote.example.test/oauth/token", + ]); + + const tokenRequest = calls.find((call) => call.url.endsWith("/oauth/token")); + const tokenBody = + tokenRequest?.init.body instanceof Uint8Array + ? new TextDecoder().decode(tokenRequest.init.body) + : String(tokenRequest?.init.body); + const tokenParams = new URLSearchParams(tokenBody); + expect(tokenParams.get("subject_token")).toBe("pairing-token"); + expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" ")); + expect(tokenParams.get("client_label")).toBe("T3 Code Test"); + }), + ); + + it.effect("does not consume a pairing credential when descriptor discovery fails", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + + yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { failDescriptor: true }), + ), + ), + Effect.flip, + ); + + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + ]); + }), + ); + + it.effect("rejects invalid pairing details before making a request", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const error = yield* preparePairingRegistration({ + host: "", + pairingCode: "", + }).pipe( + Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls))), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "configuration", + message: "Enter a backend URL.", + }); + expect(calls).toEqual([]); + }), + ); + + it.effect("updates bearer metadata while preserving the credential and identity", () => + Effect.gen(function* () { + const environmentId = EnvironmentId.make("environment-paired"); + const registration = yield* prepareBearerConnectionUpdate({ + input: { + environmentId, + label: " Renamed environment ", + httpBaseUrl: "http://100.65.180.100:3773/path", + }, + entry: Option.some({ + target: new BearerConnectionTarget({ + environmentId, + label: "Old label", + connectionId: "bearer:environment-paired", + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "bearer:environment-paired", + environmentId, + label: "Old label", + httpBaseUrl: "http://old.example.test/", + wsBaseUrl: "ws://old.example.test/", + }), + ), + }), + credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), + }); + + expect(registration).toMatchObject({ + target: { + environmentId, + label: "Renamed environment", + connectionId: "bearer:environment-paired", + }, + profile: { + environmentId, + label: "Renamed environment", + httpBaseUrl: "http://100.65.180.100:3773/", + wsBaseUrl: "ws://100.65.180.100:3773/", + }, + credential: { token: "bearer-token" }, + }); + }), + ); + + it.effect("prepares an SSH registration from the provisioned platform environment", () => + Effect.gen(function* () { + const target = { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }; + const registration = yield* prepareSshRegistration({ + target, + }).pipe( + Effect.provideService( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.succeed({ + environmentId: EnvironmentId.make("environment-ssh"), + label: "Remote development box", + bootstrap: { + target, + httpBaseUrl: "http://127.0.0.1:3201", + wsBaseUrl: "ws://127.0.0.1:3201", + pairingToken: "pairing-token", + }, + bearerToken: "bearer-token", + }), + prepare: () => Effect.die("unused"), + disconnect: () => Effect.die("unused"), + }), + ), + ); + + expect(registration).toMatchObject({ + _tag: "SshConnectionRegistration", + target: { + environmentId: "environment-ssh", + label: "Remote development box", + connectionId: "ssh:environment-ssh", + }, + profile: { + environmentId: "environment-ssh", + label: "Remote development box", + connectionId: "ssh:environment-ssh", + target, + }, + }); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts new file mode 100644 index 00000000000..e76bcd50a2c --- /dev/null +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -0,0 +1,272 @@ +import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; +import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; +import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + type ConnectionCatalogEntry, + type ConnectionCredential, + SshConnectionProfile, + SshConnectionRegistration, +} from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; +import { mapRemoteEnvironmentError } from "./errors.ts"; +import { + BearerConnectionTarget, + ConnectionBlockedError, + SshConnectionTarget, + type ConnectionAttemptError, +} from "./model.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as EnvironmentRegistry from "./registry.ts"; + +export interface PairingConnectionInput { + readonly pairingUrl?: string; + readonly host?: string; + readonly pairingCode?: string; +} + +export interface SshConnectionInput { + readonly target: DesktopSshEnvironmentTarget; + readonly label?: string; +} + +export interface BearerConnectionUpdateInput { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; +} + +export class ConnectionOnboarding extends Context.Service< + ConnectionOnboarding, + { + readonly registerPairing: ( + input: PairingConnectionInput, + ) => Effect.Effect< + EnvironmentId, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; + readonly registerSsh: ( + input: SshConnectionInput, + ) => Effect.Effect< + EnvironmentId, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; + readonly updateBearer: ( + input: BearerConnectionUpdateInput, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {} + +const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")( + function* (input: PairingConnectionInput) { + return yield* Effect.try({ + try: () => resolveRemotePairingTarget(input), + catch: (cause) => + new ConnectionBlockedError({ + reason: "configuration", + detail: cause instanceof Error ? cause.message : "The pairing details are invalid.", + }), + }); + }, +); + +export const preparePairingRegistration = Effect.fn( + "clientRuntime.connection.onboarding.preparePairingRegistration", +)(function* (input: PairingConnectionInput) { + const target = yield* resolvePairingTarget(input); + const presentation = yield* ClientCapabilities.ClientPresentation; + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: target.httpBaseUrl, + }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + const access = yield* bootstrapRemoteBearerSession({ + httpBaseUrl: target.httpBaseUrl, + credential: target.credential, + scopes: presentation.scopes, + clientMetadata: presentation.metadata, + }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + const connectionId = `bearer:${descriptor.environmentId}`; + + return new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: descriptor.environmentId, + label: descriptor.label, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: target.httpBaseUrl, + wsBaseUrl: target.wsBaseUrl, + }), + credential: new BearerConnectionCredential({ + token: access.access_token, + }), + }); +}); + +export const registerPairingConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerPairingConnection", +)(function* (input: PairingConnectionInput) { + const registration = yield* preparePairingRegistration(input); + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register(registration); + return registration.target.environmentId; +}); + +const isBearerCredential = Schema.is(BearerConnectionCredential); +const isBearerProfile = Schema.is(BearerConnectionProfile); + +export const updateBearerConnection = Effect.fn( + "clientRuntime.connection.onboarding.updateBearerConnection", +)(function* (input: BearerConnectionUpdateInput) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); + const credential = + entry?.target._tag === "BearerConnectionTarget" + ? yield* credentials.get(entry.target.connectionId) + : Option.none(); + const registration = yield* prepareBearerConnectionUpdate({ + input, + entry: Option.fromUndefinedOr(entry), + credential, + }); + yield* registry.register(registration); +}); + +export const prepareBearerConnectionUpdate = Effect.fn( + "clientRuntime.connection.onboarding.prepareBearerConnectionUpdate", +)(function* (options: { + readonly input: BearerConnectionUpdateInput; + readonly entry: Option.Option; + readonly credential: Option.Option; +}) { + const entry = Option.getOrNull(options.entry); + if ( + entry === undefined || + entry === null || + entry.target._tag !== "BearerConnectionTarget" || + Option.isNone(entry.profile) || + !isBearerProfile(entry.profile.value) + ) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: "Only saved bearer environments can be edited.", + }); + } + + const credential = options.credential; + if (Option.isNone(credential) || !isBearerCredential(credential.value)) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The saved bearer credential is unavailable.", + }); + } + + const label = options.input.label.trim(); + if (label === "") { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: "Environment label cannot be empty.", + }); + } + const httpBaseUrl = yield* Effect.try({ + try: () => normalizeHttpBaseUrl(options.input.httpBaseUrl), + catch: (cause) => + new ConnectionBlockedError({ + reason: "configuration", + detail: cause instanceof Error ? cause.message : "The environment URL is invalid.", + }), + }); + const connectionId = entry.target.connectionId; + return new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: options.input.environmentId, + label, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: options.input.environmentId, + label, + httpBaseUrl, + wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), + }), + credential: credential.value, + }); +}); + +export const prepareSshRegistration = Effect.fn( + "clientRuntime.connection.onboarding.prepareSshRegistration", +)(function* (input: SshConnectionInput) { + const gateway = yield* ClientCapabilities.SshEnvironmentGateway; + const provisioned = yield* gateway.provision(input.target); + const connectionId = `ssh:${provisioned.environmentId}`; + const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias; + + return new SshConnectionRegistration({ + target: new SshConnectionTarget({ + environmentId: provisioned.environmentId, + label, + connectionId, + }), + profile: new SshConnectionProfile({ + connectionId, + environmentId: provisioned.environmentId, + label, + target: provisioned.bootstrap.target, + }), + }); +}); + +export const registerSshConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerSshConnection", +)(function* (input: SshConnectionInput) { + const registration = yield* prepareSshRegistration(input); + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register(registration); + return registration.target.environmentId; +}); + +export const make = Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const presentation = yield* ClientCapabilities.ClientPresentation; + const httpClient = yield* HttpClient.HttpClient; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + + return ConnectionOnboarding.of({ + registerPairing: (input) => + registerPairingConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.ClientPresentation, presentation), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + registerSsh: (input) => + registerSshConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.SshEnvironmentGateway, ssh), + ), + updateBearer: (input) => + updateBearerConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ConnectionCredentialStore.ConnectionCredentialStore, credentials), + ), + }); +}); + +export const layer = Layer.effect(ConnectionOnboarding, make); diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts new file mode 100644 index 00000000000..354b003a2d4 --- /dev/null +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -0,0 +1,184 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { + BearerConnectionTarget, + ConnectionTransientError, + type SupervisorConnectionState, +} from "./model.ts"; +import { + connectionCatalogDisplayUrl, + connectionPhaseMessage, + connectionStatusText, + presentEnvironmentConnection, + presentConnectionState, +} from "./presentation.ts"; + +const TARGET = new BearerConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + connectionId: "connection-1", +}); + +const ENTRY: ConnectionCatalogEntry = { + target: TARGET, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: TARGET.connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + }), + ), +}; + +function supervisorState(overrides: Partial): SupervisorConnectionState { + return { + desired: true, + network: "online", + phase: "connecting", + stage: "preparing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + ...overrides, + }; +} + +describe("connection presentation", () => { + it("preserves profile display information without exposing credentials", () => { + expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); + }); + + it("distinguishes initial connection, reconnect, and retry errors", () => { + expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ + phase: "connecting", + error: null, + traceId: null, + }); + expect( + presentConnectionState( + supervisorState({ + phase: "connecting", + attempt: 2, + lastFailure: new ConnectionTransientError({ + reason: "transport", + detail: "Socket closed.", + traceId: "trace-previous", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Socket closed.", + traceId: "trace-previous", + }); + expect( + presentConnectionState( + supervisorState({ + phase: "backoff", + attempt: 2, + retryAt: 1, + lastFailure: new ConnectionTransientError({ + reason: "transport", + detail: "Disconnected.", + traceId: "trace-1", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Disconnected.", + traceId: "trace-1", + }); + }); + + it("preserves the latest failure while the next attempt is active", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + phase: "connecting", + stage: "opening", + attempt: 2, + lastFailure: new ConnectionTransientError({ + reason: "transport", + detail: "Relay connection timed out.", + traceId: "trace-retry", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Relay connection timed out.", + traceId: "trace-retry", + }); + }); + + it("gives offline status precedence in global messaging", () => { + expect(connectionPhaseMessage("connected", TARGET.label, "offline")).toBe("You are offline"); + }); + + it("combines reconnect progress with the latest failure", () => { + expect( + connectionStatusText({ + phase: "reconnecting", + error: "Relay request timed out.", + traceId: "trace-retry", + }), + ).toBe("Failed to connect. Reconnecting... Reason: Relay request timed out."); + }); + + it("presents the supervisor's offline state without consulting shell state", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + network: "offline", + phase: "offline", + stage: null, + }), + ), + ).toEqual({ + phase: "offline", + error: null, + traceId: null, + }); + }); + + it("presents a connected supervisor snapshot as connected", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + phase: "connected", + stage: null, + generation: 1, + }), + ), + ).toEqual({ + phase: "connected", + error: null, + traceId: null, + }); + }); + + it("preserves an explicitly available environment while offline", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + desired: false, + network: "offline", + phase: "available", + stage: null, + attempt: 0, + }), + ), + ).toEqual({ + phase: "available", + error: null, + traceId: null, + }); + }); +}); diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts new file mode 100644 index 00000000000..ec7687dfe42 --- /dev/null +++ b/packages/client-runtime/src/connection/presentation.ts @@ -0,0 +1,122 @@ +import type { ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; + +export type EnvironmentConnectionPhase = + | "available" + | "offline" + | "connecting" + | "reconnecting" + | "connected" + | "error"; + +export interface EnvironmentConnectionPresentation { + readonly phase: EnvironmentConnectionPhase; + readonly error: string | null; + readonly traceId: string | null; +} + +export interface EnvironmentPresentation { + readonly entry: ConnectionCatalogEntry; + readonly connection: EnvironmentConnectionPresentation; + readonly serverConfig: ServerConfig | null; +} + +export function presentConnectionState( + state: SupervisorConnectionState, +): EnvironmentConnectionPresentation { + switch (state.phase) { + case "available": + return { phase: "available", error: null, traceId: null }; + case "offline": + return { phase: "offline", error: null, traceId: null }; + case "connecting": + return { + phase: state.attempt <= 1 && state.lastFailure === null ? "connecting" : "reconnecting", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + case "connected": + return { phase: "connected", error: null, traceId: null }; + case "backoff": + return { + phase: "reconnecting", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + case "blocked": + return { + phase: "error", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + } +} + +export function connectionStatusText(connection: EnvironmentConnectionPresentation): string { + switch (connection.phase) { + case "available": + return "Available"; + case "offline": + return "Offline"; + case "connecting": + return "Connecting..."; + case "reconnecting": + return connection.error + ? `Failed to connect. Reconnecting... Reason: ${connection.error}` + : "Reconnecting..."; + case "connected": + return "Connected"; + case "error": + return connection.error + ? `Connection failed. Reason: ${connection.error}` + : "Connection failed"; + } +} + +export function presentEnvironmentConnection( + state: SupervisorConnectionState, +): EnvironmentConnectionPresentation { + return presentConnectionState(state); +} + +export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { + switch (entry.target._tag) { + case "PrimaryConnectionTarget": + return entry.target.httpBaseUrl; + case "RelayConnectionTarget": + return null; + case "BearerConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile" + ? entry.profile.value.httpBaseUrl + : null; + case "SshConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile" + ? `${entry.profile.value.target.username}@${entry.profile.value.target.hostname}` + : null; + } +} + +export function connectionPhaseMessage( + phase: EnvironmentConnectionPhase, + label: string, + networkStatus: NetworkStatus, +): string { + if (networkStatus === "offline" || phase === "offline") { + return "You are offline"; + } + switch (phase) { + case "available": + return "Available"; + case "connecting": + return `Connecting to ${label}...`; + case "reconnecting": + return `Reconnecting to ${label}...`; + case "connected": + return "Connected"; + case "error": + return "Connection failed"; + } +} diff --git a/packages/client-runtime/src/connection/profileStore.ts b/packages/client-runtime/src/connection/profileStore.ts new file mode 100644 index 00000000000..3432a7fe16e --- /dev/null +++ b/packages/client-runtime/src/connection/profileStore.ts @@ -0,0 +1,24 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Option from "effect/Option"; + +import type { ConnectionProfile } from "./catalog.ts"; +import type { ConnectionAttemptError } from "./model.ts"; + +export class ConnectionProfileStore extends Context.Service< + ConnectionProfileStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: (profile: ConnectionProfile) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/profileStore/ConnectionProfileStore") {} + +export const make = (service: ConnectionProfileStore["Service"]) => + ConnectionProfileStore.of(service); + +export const layer = (service: ConnectionProfileStore["Service"]) => + Layer.succeed(ConnectionProfileStore, make(service)); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts new file mode 100644 index 00000000000..885ba4cb781 --- /dev/null +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -0,0 +1,941 @@ +import { + type DesktopSshEnvironmentTarget, + EnvironmentId, + type OrchestrationShellSnapshot, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as TokenStore from "../authorization/tokenStore.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + type ConnectionRegistration, + PrimaryConnectionRegistration, + RelayConnectionRegistration, + SshConnectionProfile, + type ConnectionCredential, + type ConnectionProfile, +} from "./catalog.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; +import * as ConnectionDriver from "./driver.ts"; +import { + ConnectionTransientError, + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; +import * as EnvironmentRegistry from "./registry.ts"; +import * as RpcSession from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const SECOND_TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-2"), + label: "Second environment", + httpBaseUrl: "https://environment-2.example.test", + wsBaseUrl: "wss://environment-2.example.test", +}); + +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: null, + target: TARGET, +}; + +const RELAY_TARGET = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("environment-relay"), + label: "Relay environment", +}); +const SECOND_RELAY_TARGET = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("environment-relay-2"), + label: "Second relay environment", +}); + +const BEARER_TARGET = new BearerConnectionTarget({ + environmentId: EnvironmentId.make("environment-bearer"), + label: "Bearer environment", + connectionId: "bearer-connection", +}); +const BEARER_PROFILE = new BearerConnectionProfile({ + connectionId: BEARER_TARGET.connectionId, + environmentId: BEARER_TARGET.environmentId, + label: BEARER_TARGET.label, + httpBaseUrl: "https://bearer.example.test", + wsBaseUrl: "wss://bearer.example.test", +}); +const BEARER_CREDENTIAL = new BearerConnectionCredential({ + token: "bearer-token", +}); + +const SSH_TARGET: DesktopSshEnvironmentTarget = { + alias: "test", + hostname: "test.example.test", + username: "developer", + port: 22, +}; +const SSH_CONNECTION = new SshConnectionTarget({ + environmentId: EnvironmentId.make("environment-ssh"), + label: "SSH environment", + connectionId: "ssh-connection", +}); +const SSH_PROFILE = new SshConnectionProfile({ + connectionId: SSH_CONNECTION.connectionId, + environmentId: SSH_CONNECTION.environmentId, + label: SSH_CONNECTION.label, + target: SSH_TARGET, +}); + +const CACHED_SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", +}; + +interface SessionControl { + readonly closed: Deferred.Deferred; +} + +const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( + initialTargets: ReadonlyArray, + initialProfiles: ReadonlyArray = [], + initialCredentials: ReadonlyArray = [], + options?: { + readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect; + readonly beforeRegistrationRegister?: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly beforeRegistrationRemove?: ( + target: ConnectionTarget, + ) => Effect.Effect; + }, +) { + const storedTargets = yield* Ref.make( + new Map(initialTargets.map((target) => [target.environmentId, target])), + ); + const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]])); + const cacheClears = yield* Ref.make>([]); + const ownedDataClears = yield* Ref.make>([]); + const sessions = yield* Ref.make>([]); + const releasedSessions = yield* Ref.make(0); + const storedProfiles = yield* Ref.make( + new Map(initialProfiles.map((profile) => [profile.connectionId, profile])), + ); + const profileReadCount = yield* Ref.make(0); + const storedCredentials = yield* Ref.make(new Map(initialCredentials)); + const storedRemoteTokens = yield* Ref.make( + new Map([ + [ + SSH_CONNECTION.environmentId, + new TokenStore.RemoteDpopAccessToken({ + environmentId: SSH_CONNECTION.environmentId, + label: SSH_CONNECTION.label, + endpoint: { + httpBaseUrl: "https://ssh.example.test", + wsBaseUrl: "wss://ssh.example.test", + providerKind: "cloudflare_tunnel", + }, + accessToken: "cached-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint", + }), + ], + ]), + ); + const disconnectedSshTargets = yield* Ref.make>([]); + + const targetStore = Persistence.ConnectionTargetStore.of({ + list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), + }); + const registrationStore = Persistence.ConnectionRegistrationStore.of({ + register: (registration) => + Effect.gen(function* () { + yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void; + yield* Ref.update(storedTargets, (current) => { + const next = new Map(current); + next.set(registration.target.environmentId, registration.target); + return next; + }); + switch (registration._tag) { + case "RelayConnectionRegistration": + return; + case "BearerConnectionRegistration": + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(registration.profile.connectionId, registration.profile); + return next; + }); + yield* Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.set(registration.target.connectionId, registration.credential); + return next; + }); + return; + case "SshConnectionRegistration": + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(registration.profile.connectionId, registration.profile); + return next; + }); + } + }), + remove: (target) => + Effect.gen(function* () { + yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void; + yield* Ref.update(storedTargets, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }); + if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") { + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.delete(target.connectionId); + return next; + }); + yield* Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.delete(target.connectionId); + return next; + }); + } + yield* Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }); + }), + }); + const cacheStore = Persistence.EnvironmentCacheStore.of({ + loadShell: (environmentId) => + Ref.get(shellCache).pipe( + Effect.map((cache) => Option.fromUndefinedOr(cache.get(environmentId))), + ), + saveShell: (environmentId, snapshot) => + Ref.update(shellCache, (current) => { + const next = new Map(current); + next.set(environmentId, snapshot); + return next; + }), + loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), + saveThread: (_environmentId, _thread) => Effect.void, + removeThread: (_environmentId, _threadId) => Effect.void, + clear: (environmentId) => + Ref.update(shellCache, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }).pipe( + Effect.andThen( + Ref.update(cacheClears, (environmentIds) => [...environmentIds, environmentId]), + ), + ), + }); + const ownedDataCleanup = Persistence.EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), + }); + const networkStatus = yield* SubscriptionRef.make<"unknown" | "offline" | "online">("online"); + const connectivity = Connectivity.Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({ + get: (connectionId) => + Ref.update(profileReadCount, (count) => count + 1).pipe( + Effect.andThen(Ref.get(storedProfiles)), + Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), + ), + put: (profile) => + Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(profile.connectionId, profile); + return next; + }), + remove: (connectionId) => + Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.delete(connectionId); + return next; + }), + }); + const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({ + get: (connectionId) => + Ref.get(storedCredentials).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), + ), + put: (connectionId, credential) => + Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.set(connectionId, credential); + return next; + }), + remove: (connectionId) => + Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.delete(connectionId); + return next; + }), + }); + const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + Ref.get(storedRemoteTokens).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + ), + put: (token) => + Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.set(token.environmentId, token); + return next; + }), + remove: (environmentId) => + Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }), + }); + const sshGateway = ClientCapabilities.SshEnvironmentGateway.of({ + provision: () => Effect.die(new Error("SSH provisioning is not used.")), + prepare: () => Effect.die(new Error("SSH preparation is not used.")), + disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), + }); + const driver = ConnectionDriver.ConnectionDriver.of({ + connect: (entry, reportProgress) => + Effect.gen(function* () { + const target = entry.target; + const prepared = { + ...PREPARED, + environmentId: target.environmentId, + label: target.label, + target, + }; + yield* reportProgress({ stage: "preparing" }); + yield* reportProgress({ stage: "opening", prepared }); + yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void; + const closed = yield* Deferred.make(); + yield* Ref.update(sessions, (current) => [...current, { closed }]); + const session = yield* Effect.acquireRelease( + Effect.succeed({ + client: {} as RpcSession.RpcSession["client"], + initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + ready: Effect.void, + probe: Effect.void, + closed: Deferred.await(closed), + } satisfies RpcSession.RpcSession), + () => Ref.update(releasedSessions, (count) => count + 1), + ); + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session }; + }), + }); + + const cacheLayer = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore); + const layer = EnvironmentRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Persistence.ConnectionTargetStore, targetStore), + Layer.succeed(Persistence.ConnectionRegistrationStore, registrationStore), + Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), + Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed(ClientCapabilities.SshEnvironmentGateway, sshGateway), + Layer.succeed(Connectivity.Connectivity, connectivity), + Layer.succeed( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }), + ), + Layer.succeed(ConnectionDriver.ConnectionDriver, driver), + cacheLayer, + Layer.succeed(Persistence.EnvironmentOwnedDataCleanup, ownedDataCleanup), + ), + ), + ); + + return { + layer, + storedTargets, + shellCache, + cacheClears, + ownedDataClears, + sessions, + releasedSessions, + storedProfiles, + profileReadCount, + storedCredentials, + storedRemoteTokens, + disconnectedSshTargets, + networkStatus, + }; +}); + +function awaitConnectionState( + registry: EnvironmentRegistry.EnvironmentRegistry["Service"], + environmentId: EnvironmentId, + predicate: (state: SupervisorConnectionState) => boolean, +) { + return Effect.gen(function* () { + const current = yield* registry.state(environmentId); + if (predicate(current)) { + return current; + } + return yield* registry + .stateChanges(environmentId) + .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); + }); +} + +describe("EnvironmentRegistry", () => { + it.effect("hydrates connection profiles into catalog entries", () => + Effect.gen(function* () { + const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const entry = (yield* SubscriptionRef.get(registry.entries)).get( + SSH_CONNECTION.environmentId, + ); + + expect(entry?.target).toEqual(SSH_CONNECTION); + expect(Option.getOrThrow(entry?.profile ?? Option.none())).toEqual(SSH_PROFILE); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("publishes network status changes independently of connection state", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const offline = yield* Effect.forkChild( + SubscriptionRef.changes(registry.networkStatus).pipe( + Stream.filter((status) => status === "offline"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + ); + + yield* SubscriptionRef.set(harness.networkStatus, "offline"); + + expect(yield* Fiber.join(offline)).toBe("offline"); + expect(yield* SubscriptionRef.get(registry.networkStatus)).toBe("offline"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts persisted environments independently", () => + Effect.gen(function* () { + const bothLoadsStarted = yield* Deferred.make(); + const releaseLoads = yield* Deferred.make(); + const loadCount = yield* Ref.make(0); + const harness = yield* makeHarness([TARGET, SECOND_TARGET], [], [], { + beforeSessionConnect: () => + Ref.updateAndGet(loadCount, (count) => count + 1).pipe( + Effect.tap((count) => + count === 2 ? Deferred.succeed(bothLoadsStarted, undefined) : Effect.void, + ), + Effect.andThen(Deferred.await(releaseLoads)), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const start = yield* Effect.forkChild(registry.start); + + yield* Deferred.await(bothLoadsStarted).pipe(Effect.timeout("1 second")); + yield* Deferred.succeed(releaseLoads, undefined); + yield* Fiber.join(start); + + expect(yield* Ref.get(loadCount)).toBe(2); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("exposes the current RPC generation to late query subscribers", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const generation = yield* registry + .runStream( + TARGET.environmentId, + Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + Stream.concat( + Stream.fromEffect(SubscriptionRef.get(supervisor.state)), + SubscriptionRef.changes(supervisor.state), + ).pipe( + Stream.filterMap((state) => + state.phase === "connected" + ? Result.succeed(state.generation) + : Result.failVoid, + ), + Stream.changes, + ), + ), + ), + ), + ) + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + + expect(generation).toBe(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("preserves cached data on connection failure and clears it on explicit removal", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + const controls = yield* Ref.get(harness.sessions); + expect(controls).toHaveLength(1); + const active = controls[0]; + expect(active).toBeDefined(); + expect((yield* Ref.get(harness.shellCache)).get(TARGET.environmentId)).toEqual( + CACHED_SNAPSHOT, + ); + + const retryFiber = yield* Effect.forkChild( + awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "backoff", + ), + ); + yield* Effect.yieldNow; + yield* Deferred.fail( + active!.closed, + new ConnectionTransientError({ + reason: "transport", + detail: "Disconnected.", + }), + ); + yield* Fiber.join(retryFiber); + expect((yield* Ref.get(harness.shellCache)).get(TARGET.environmentId)).toEqual( + CACHED_SNAPSHOT, + ); + + yield* registry.remove(TARGET.environmentId); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + expect((yield* Ref.get(harness.shellCache)).has(TARGET.environmentId)).toBe(false); + expect(yield* Ref.get(harness.cacheClears)).toEqual([TARGET.environmentId]); + expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe( + false, + ); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("persists and starts a newly registered environment", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect((yield* Ref.get(harness.storedTargets)).get(RELAY_TARGET.environmentId)).toEqual( + RELAY_TARGET, + ); + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("moves durable streams to a replacement supervisor", () => + Effect.gen(function* () { + const replacement = new RelayConnectionTarget({ + environmentId: RELAY_TARGET.environmentId, + label: "Replacement relay environment", + }); + const harness = yield* makeHarness([RELAY_TARGET]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const firstObserved = yield* Deferred.make(); + const secondObserved = yield* Deferred.make(); + const labels = yield* Ref.make>([]); + yield* registry.start; + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const subscription = yield* Effect.forkChild( + registry + .followStream( + RELAY_TARGET.environmentId, + Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + Stream.concat(Stream.succeed(supervisor.target.label), Stream.never), + ), + ), + ), + ) + .pipe( + Stream.tap((label) => + Ref.updateAndGet(labels, (current) => [...current, label]).pipe( + Effect.flatMap((current) => + current.length === 1 + ? Deferred.succeed(firstObserved, undefined) + : Deferred.succeed(secondObserved, undefined), + ), + ), + ), + Stream.runDrain, + ), + ); + + yield* Deferred.await(firstObserved).pipe(Effect.timeout("1 second")); + yield* registry.register(new RelayConnectionRegistration({ target: replacement })); + yield* Deferred.await(secondObserved).pipe(Effect.timeout("1 second")); + yield* Fiber.interrupt(subscription); + + expect(yield* Ref.get(labels)).toEqual([RELAY_TARGET.label, replacement.label]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("ignores retry signals for environments that are no longer registered", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.retryNow(EnvironmentId.make("removed-environment")); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("removes all relay-owned data without touching non-cloud connections", () => + Effect.gen(function* () { + const harness = yield* makeHarness( + [RELAY_TARGET, SECOND_RELAY_TARGET, BEARER_TARGET], + [BEARER_PROFILE], + [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.removeRelayEnvironments(); + + const targets = yield* Ref.get(harness.storedTargets); + expect(targets.has(RELAY_TARGET.environmentId)).toBe(false); + expect(targets.has(SECOND_RELAY_TARGET.environmentId)).toBe(false); + expect(targets.get(BEARER_TARGET.environmentId)).toEqual(BEARER_TARGET); + expect(yield* Ref.get(harness.cacheClears)).toEqual( + expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]), + ); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual( + expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]), + ); + expect( + (yield* SubscriptionRef.get(registry.entries)).has(BEARER_TARGET.environmentId), + ).toBe(true); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("keeps the runtime registered when durable removal fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET], [], [], { + beforeRegistrationRemove: () => + Effect.fail( + new Persistence.ConnectionPersistenceError({ + operation: "remove-connection", + message: "Storage is unavailable.", + }), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const error = yield* Effect.flip(registry.removeRelayEnvironments()); + + expect(error._tag).toBe("ConnectionPersistenceError"); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); + expect((yield* SubscriptionRef.get(registry.entries)).has(RELAY_TARGET.environmentId)).toBe( + true, + ); + expect((yield* Ref.get(harness.storedTargets)).has(RELAY_TARGET.environmentId)).toBe(true); + expect(yield* Ref.get(harness.cacheClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts a newly paired bearer environment without re-reading its profile", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register( + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + yield* awaitConnectionState( + registry, + BEARER_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect(yield* Ref.get(harness.profileReadCount)).toBe(0); + expect( + Option.getOrThrow( + (yield* SubscriptionRef.get(registry.entries)).get(BEARER_TARGET.environmentId) + ?.profile ?? Option.none(), + ), + ).toEqual(BEARER_PROFILE); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts platform environments without persisting or removing them", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + + const error = yield* Effect.flip(registry.remove(TARGET.environmentId)); + expect(error._tag).toBe("PlatformEnvironmentRemovalError"); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("gives a primary platform registration precedence over persisted registrations", () => + Effect.gen(function* () { + const shadowedTarget = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: "Shadowed relay environment", + }); + const harness = yield* makeHarness([shadowedTarget]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); + + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + + yield* registry.register(new RelayConnectionRegistration({ target: shadowedTarget })); + + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("rechecks platform ownership after waiting for the environment lease", () => + Effect.gen(function* () { + const registrationStarted = yield* Deferred.make(); + const continueRegistration = yield* Deferred.make(); + const shadowedTarget = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: "Shadowed relay environment", + }); + const harness = yield* makeHarness([], [], [], { + beforeRegistrationRegister: () => + Deferred.succeed(registrationStarted, undefined).pipe( + Effect.andThen(Deferred.await(continueRegistration)), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const persistedRegistration = yield* registry + .register(new RelayConnectionRegistration({ target: shadowedTarget })) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(registrationStarted); + + const platformRegistration = yield* registry + .registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + const removal = yield* Effect.flip(registry.remove(TARGET.environmentId)).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.succeed(continueRegistration, undefined); + yield* Fiber.join(persistedRegistration); + yield* Fiber.join(platformRegistration); + const error = yield* Fiber.join(removal); + + expect(error._tag).toBe("PlatformEnvironmentRemovalError"); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("does not reacquire a runtime while its registration is being removed", () => + Effect.gen(function* () { + const removalStarted = yield* Deferred.make(); + const continueRemoval = yield* Deferred.make(); + const harness = yield* makeHarness([TARGET], [], [], { + beforeRegistrationRemove: () => + Deferred.succeed(removalStarted, undefined).pipe( + Effect.andThen(Deferred.await(continueRemoval)), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const removal = yield* Effect.forkChild(registry.remove(TARGET.environmentId)); + yield* Deferred.await(removalStarted); + + const stateLookup = yield* Effect.forkChild( + Effect.flip(registry.state(TARGET.environmentId)), + ); + yield* Effect.yieldNow; + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + + yield* Deferred.succeed(continueRemoval, undefined); + yield* Fiber.join(removal); + const error = yield* Fiber.join(stateLookup); + expect(error._tag).toBe("EnvironmentNotRegisteredError"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("retains a healthy runtime when the platform repeats an identical registration", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const registration = new PrimaryConnectionRegistration({ target: TARGET }); + yield* registry.registerPlatform(registration); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + yield* registry.registerPlatform(registration); + + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("removes all owned SSH state only on explicit removal", () => + Effect.gen(function* () { + const harness = yield* makeHarness( + [SSH_CONNECTION], + [SSH_PROFILE], + [ + [ + SSH_CONNECTION.connectionId, + new BearerConnectionCredential({ token: "temporary-token" }), + ], + ], + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* registry.remove(SSH_CONNECTION.environmentId); + + expect((yield* Ref.get(harness.storedProfiles)).has(SSH_CONNECTION.connectionId)).toBe( + false, + ); + expect((yield* Ref.get(harness.storedCredentials)).has(SSH_CONNECTION.connectionId)).toBe( + false, + ); + expect((yield* Ref.get(harness.storedRemoteTokens)).has(SSH_CONNECTION.environmentId)).toBe( + false, + ); + expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]); + }).pipe(Effect.provide(harness.layer)); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts new file mode 100644 index 00000000000..3a95185d835 --- /dev/null +++ b/packages/client-runtime/src/connection/registry.ts @@ -0,0 +1,581 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import * as ClientCapabilities from "../platform/capabilities.ts"; +import { + type ConnectionCatalogEntry, + type ConnectionRegistration, + type PrimaryConnectionRegistration, + SshConnectionProfile, + connectionRegistrationCatalogEntry, +} from "./catalog.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; +import * as Connectivity from "./connectivity.ts"; +import type { + ConnectionAttemptError, + ConnectionTarget, + NetworkStatus, + SupervisorConnectionState, +} from "./model.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionDriver from "./driver.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; + +const isSshConnectionProfile = Schema.is(SshConnectionProfile); + +export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass()( + "EnvironmentNotRegisteredError", + { + environmentId: EnvironmentId, + }, +) { + override get message(): string { + return `Environment ${this.environmentId} is not registered.`; + } +} + +export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass()( + "PlatformEnvironmentRemovalError", + { + environmentId: EnvironmentId, + }, +) { + override get message(): string { + return `Platform-managed environment ${this.environmentId} cannot be removed.`; + } +} + +export class EnvironmentRegistry extends Context.Service< + EnvironmentRegistry, + { + readonly entries: SubscriptionRef.SubscriptionRef< + ReadonlyMap + >; + readonly networkStatus: SubscriptionRef.SubscriptionRef; + readonly start: Effect.Effect; + readonly register: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; + readonly remove: ( + environmentId: EnvironmentId, + ) => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | EnvironmentNotRegisteredError + | PlatformEnvironmentRemovalError + >; + readonly removeRelayEnvironments: () => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | PlatformEnvironmentRemovalError + >; + readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; + readonly state: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + readonly stateChanges: ( + environmentId: EnvironmentId, + ) => Stream.Stream; + readonly run: ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ) => Effect.Effect< + A, + E | EnvironmentNotRegisteredError, + Exclude + >; + readonly runStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream< + A, + E | EnvironmentNotRegisteredError, + Exclude + >; + readonly followStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream>; + } +>()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {} + +interface EnvironmentServiceScope { + readonly entry: ConnectionCatalogEntry; + readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; + readonly scope: Scope.Closeable; +} + +export const make = Effect.gen(function* () { + const storage = yield* Persistence.ConnectionTargetStore; + const registrations = yield* Persistence.ConnectionRegistrationStore; + const cache = yield* Persistence.EnvironmentCacheStore; + const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; + const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; + const connectivity = yield* Connectivity.Connectivity; + const driver = yield* ConnectionDriver.ConnectionDriver; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const persistedTargets = yield* storage.list; + const initialEntries = new Map( + yield* Effect.forEach( + persistedTargets, + Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* (target) { + const profile = + target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" + ? yield* profiles.get(target.connectionId) + : Option.none(); + return [ + target.environmentId, + { target, profile } satisfies ConnectionCatalogEntry, + ] as const; + }), + { concurrency: "unbounded" }, + ), + ); + const entries = + yield* SubscriptionRef.make>(initialEntries); + const networkStatus = yield* SubscriptionRef.make(yield* connectivity.status); + const serviceScopes = yield* SubscriptionRef.make< + ReadonlyMap + >(new Map()); + const platformEnvironmentIds = yield* Ref.make>(new Set()); + const persistedTargetsByEnvironment = yield* Ref.make< + ReadonlyMap + >(new Map(persistedTargets.map((target) => [target.environmentId, target]))); + interface LeaseLock { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; + } + + const leaseLocks = yield* Ref.make>(new Map()); + const leaseLocksGuard = yield* Semaphore.make(1); + const started = yield* Ref.make(false); + + const withLeaseLock = ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ): Effect.Effect => + Effect.acquireUseRelease( + leaseLocksGuard.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(leaseLocks); + const existing = current.get(environmentId); + if (existing !== undefined) { + yield* Ref.set( + leaseLocks, + new Map(current).set(environmentId, { + semaphore: existing.semaphore, + users: existing.users + 1, + }), + ); + return existing.semaphore; + } + const semaphore = yield* Semaphore.make(1); + yield* Ref.set(leaseLocks, new Map(current).set(environmentId, { semaphore, users: 1 })); + return semaphore; + }), + ), + (semaphore) => semaphore.withPermits(1)(effect), + (semaphore) => + leaseLocksGuard.withPermits(1)( + Ref.update(leaseLocks, (current) => { + const existing = current.get(environmentId); + if (existing === undefined || existing.semaphore !== semaphore) { + return current; + } + const next = new Map(current); + if (existing.users === 1) { + next.delete(environmentId); + } else { + next.set(environmentId, { + semaphore, + users: existing.users - 1, + }); + } + return next; + }), + ), + ).pipe(Effect.withSpan("EnvironmentRegistry.withLeaseLock")); + + const getEntry = Effect.fn("EnvironmentRegistry.getEntry")(function* ( + environmentId: EnvironmentId, + ) { + const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); + if (entry === undefined) { + return yield* new EnvironmentNotRegisteredError({ + environmentId, + }); + } + return entry; + }); + + const closeServiceScope = Effect.fn("EnvironmentRegistry.closeServiceScope")(function* ( + environmentId: EnvironmentId, + ) { + const current = yield* SubscriptionRef.get(serviceScopes); + const lease = current.get(environmentId); + if (lease === undefined) { + return; + } + const next = new Map(current); + next.delete(environmentId); + yield* SubscriptionRef.set(serviceScopes, next); + yield* Scope.close(lease.scope, Exit.void); + }); + + const createServiceScope = Effect.fn("EnvironmentRegistry.createServiceScope")( + (entry: ConnectionCatalogEntry) => + Effect.uninterruptible( + Effect.gen(function* () { + const environmentId = entry.target.environmentId; + const scope = yield* Scope.make(); + const supervisor = yield* EnvironmentSupervisor.make(entry, { + initiallyDesired: false, + }).pipe( + Effect.provideService(Connectivity.Connectivity, connectivity), + Effect.provideService(ConnectionDriver.ConnectionDriver, driver), + Effect.provideService(ConnectionWakeups.ConnectionWakeups, wakeups), + Scope.provide(scope), + Effect.onError(() => Scope.close(scope, Exit.void)), + ); + yield* supervisor.connect; + yield* SubscriptionRef.update(serviceScopes, (current) => { + const next = new Map(current); + next.set(environmentId, { entry, supervisor, scope }); + return next; + }); + return supervisor; + }), + ), + ); + + const acquireSupervisor = Effect.fn("EnvironmentRegistry.acquireSupervisor")(function* ( + environmentId: EnvironmentId, + ) { + return yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + const entry = yield* getEntry(environmentId); + const existing = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + if (existing !== undefined) { + if (Equal.equals(existing.entry, entry)) { + return existing.supervisor; + } + yield* closeServiceScope(environmentId); + } + return yield* createServiceScope(entry); + }), + ); + }); + + const run: EnvironmentRegistry["Service"]["run"] = Effect.fn("EnvironmentRegistry.run")( + function* (environmentId: EnvironmentId, effect: Effect.Effect) { + const supervisor = yield* acquireSupervisor(environmentId); + return yield* Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ); + }, + ); + + const runStream: EnvironmentRegistry["Service"]["runStream"] = ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => + Stream.unwrap( + acquireSupervisor(environmentId).pipe( + Effect.map((supervisor) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ), + ), + ); + + const followStream: EnvironmentRegistry["Service"]["followStream"] = ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => + Stream.concat( + Stream.fromEffect(SubscriptionRef.get(entries)), + SubscriptionRef.changes(entries), + ).pipe( + Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + Stream.changes, + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: () => + Stream.unwrap( + acquireSupervisor(environmentId).pipe( + Effect.match({ + onFailure: () => Stream.empty, + onSuccess: (supervisor) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ), + }), + ), + ), + }), + ), + ); + + const start = Effect.gen(function* () { + if (yield* Ref.getAndSet(started, true)) { + return; + } + yield* Effect.forEach( + persistedTargets, + (target) => + acquireSupervisor(target.environmentId).pipe( + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + ), + { + concurrency: "unbounded", + discard: true, + }, + ); + }).pipe(Effect.withSpan("EnvironmentRegistry.start")); + + const installEntryLocked = Effect.fn("EnvironmentRegistry.installEntryLocked")(function* ( + entry: ConnectionCatalogEntry, + options?: { readonly retainEquivalentRuntime?: boolean }, + ) { + const target = entry.target; + const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId); + const existingScope = (yield* SubscriptionRef.get(serviceScopes)).get(target.environmentId); + if ( + options?.retainEquivalentRuntime === true && + previous !== undefined && + Equal.equals(previous, entry) && + existingScope !== undefined && + Equal.equals(existingScope.entry, entry) + ) { + return; + } + + yield* closeServiceScope(target.environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.set(target.environmentId, entry); + return next; + }); + yield* createServiceScope(entry); + }); + + const register = Effect.fn("EnvironmentRegistry.register")(function* ( + registration: ConnectionRegistration, + ) { + const entry = connectionRegistrationCatalogEntry(registration); + const environmentId = entry.target.environmentId; + yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { + return; + } + yield* registrations.register(registration); + yield* Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.set(environmentId, registration.target); + return next; + }); + yield* installEntryLocked(entry); + }), + ); + }); + + const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* ( + registration: PrimaryConnectionRegistration, + ) { + const entry = connectionRegistrationCatalogEntry(registration); + const target = entry.target; + yield* withLeaseLock( + target.environmentId, + Effect.gen(function* () { + yield* Ref.update(platformEnvironmentIds, (current) => { + const next = new Set(current); + next.add(target.environmentId); + return next; + }); + + const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( + target.environmentId, + ); + if (persistedTarget !== undefined) { + yield* registrations.remove(persistedTarget).pipe( + Effect.tap(() => + Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }), + ), + Effect.catch((error) => + Effect.logWarning( + "Could not remove a persisted registration shadowed by the primary environment.", + { + environmentId: target.environmentId, + error, + }, + ), + ), + ); + } + + yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); + }), + ); + }); + + const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) { + return yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { + return yield* new PlatformEnvironmentRemovalError({ + environmentId, + }); + } + const target = (yield* getEntry(environmentId)).target; + const profile = + target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" + ? yield* profiles.get(target.connectionId) + : Option.none(); + + yield* registrations.remove(target); + yield* Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* closeServiceScope(environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* Effect.all( + [ + cache.clear(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear cached environment data after removal.", { + environmentId, + error, + }), + ), + ), + ownedDataCleanup.clear(environmentId), + ], + { concurrency: "unbounded", discard: true }, + ); + + if ( + target._tag === "SshConnectionTarget" && + Option.isSome(profile) && + isSshConnectionProfile(profile.value) + ) { + yield* ssh.disconnect(profile.value.target).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not disconnect the managed SSH environment.", { + environmentId, + error, + }), + ), + Effect.ignore, + ); + } + }), + ); + }); + + const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")( + function* () { + const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()] + .filter((entry) => entry.target._tag === "RelayConnectionTarget") + .map((entry) => entry.target.environmentId); + + yield* Effect.forEach( + relayEnvironmentIds, + (environmentId) => + remove(environmentId).pipe( + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + ), + { + concurrency: "unbounded", + discard: true, + }, + ); + }, + ); + + const retryNow = (environmentId: EnvironmentId) => + acquireSupervisor(environmentId).pipe( + Effect.flatMap((supervisor) => supervisor.retryNow), + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + Effect.withSpan("EnvironmentRegistry.retryNow"), + ); + const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) { + const supervisor = yield* acquireSupervisor(environmentId); + return yield* SubscriptionRef.get(supervisor.state); + }); + const stateChanges = (environmentId: EnvironmentId) => + followStream( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), + ), + ), + ); + + yield* Effect.addFinalizer(() => + SubscriptionRef.get(serviceScopes).pipe( + Effect.flatMap((current) => + Effect.forEach(current.values(), (lease) => Scope.close(lease.scope, Exit.void), { + concurrency: "unbounded", + discard: true, + }), + ), + ), + ); + yield* connectivity.changes.pipe( + Stream.runForEach((status) => SubscriptionRef.set(networkStatus, status)), + Effect.forkScoped, + ); + + return EnvironmentRegistry.of({ + entries, + networkStatus, + start, + register, + registerPlatform, + remove, + removeRelayEnvironments, + retryNow, + state, + stateChanges, + run, + runStream, + followStream, + }); +}); + +export const layer = Layer.effect(EnvironmentRegistry, make); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts new file mode 100644 index 00000000000..0469e459d16 --- /dev/null +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -0,0 +1,462 @@ +import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts"; +import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Tracer from "effect/Tracer"; + +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + type ConnectionCatalogEntry, + SshConnectionProfile, + type ConnectionCredential, + type ConnectionProfile, +} from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; +import { + BearerConnectionTarget, + ConnectionTransientError, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, +} from "./model.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const ENDPOINT = { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel" as const, +}; +const SSH_TARGET: DesktopSshEnvironmentTarget = { + alias: "development", + hostname: "development.example.test", + username: "developer", + port: 22, +}; + +function catalogEntry( + target: ConnectionTarget, + profile: Option.Option = Option.none(), +): ConnectionCatalogEntry { + return { target, profile }; +} + +function unsupported(name: string): Effect.Effect { + return Effect.die(new Error(`Unexpected relay call: ${name}`)); +} + +function collectingTracer(spans: Array): Tracer.Tracer { + return Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + spans.push(span.name); + }; + return span; + }, + }); +} + +function relayClient( + connectEnvironment: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"], +) { + return ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => unsupported("listEnvironments"), + listDevices: () => unsupported("listDevices"), + createEnvironmentLinkChallenge: () => unsupported("createEnvironmentLinkChallenge"), + linkEnvironment: () => unsupported("linkEnvironment"), + unlinkEnvironment: () => unsupported("unlinkEnvironment"), + getEnvironmentStatus: () => unsupported("getEnvironmentStatus"), + connectEnvironment, + registerDevice: () => unsupported("registerDevice"), + unregisterDevice: () => unsupported("unregisterDevice"), + registerLiveActivity: () => unsupported("registerLiveActivity"), + resetTokenCache: Effect.void, + }); +} + +const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { + readonly profiles?: ReadonlyArray; + readonly credentials?: ReadonlyArray; + readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"]; + readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; + readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly primaryBearerToken?: string; + readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; +}) => { + const profiles = new Map( + (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), + ); + const credentials = new Map(options?.credentials ?? []); + + const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({ + get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))), + put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)), + remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)), + }); + const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({ + get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))), + put: (connectionId, credential) => + Effect.sync(() => void credentials.set(connectionId, credential)), + remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)), + }); + const remote = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({ + authorizeBearer: + options?.authorizeBearer ?? + ((input) => + Effect.succeed({ + environmentId: input.expectedEnvironmentId, + label: "Authorized bearer environment", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "wss://authorized.example.test/ws?wsTicket=bearer", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + })), + authorizeDpop: + options?.authorizeDpop ?? + ((input) => + input.obtainBootstrap.pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Authorized relay environment", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + )), + }); + const ssh = ClientCapabilities.SshEnvironmentGateway.of({ + provision: () => Effect.die("unused"), + prepare: + options?.prepareSsh ?? + (() => + Effect.succeed({ + bootstrap: { + target: SSH_TARGET, + httpBaseUrl: "http://127.0.0.1:4010", + wsBaseUrl: "ws://127.0.0.1:4010", + pairingToken: null, + }, + bearerToken: "ssh-bearer", + })), + disconnect: () => Effect.void, + }); + + const dependencies = Layer.mergeAll( + Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), + Layer.succeed( + ClientCapabilities.CloudSession, + ClientCapabilities.CloudSession.of({ clerkToken: Effect.succeed("clerk-session") }), + ), + Layer.succeed( + ClientCapabilities.PrimaryEnvironmentAuth, + ClientCapabilities.PrimaryEnvironmentAuth.of({ + bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), + }), + ), + Layer.succeed( + ClientCapabilities.RelayDeviceIdentity, + ClientCapabilities.RelayDeviceIdentity.of({ + deviceId: Effect.succeed(Option.some("device-1")), + }), + ), + Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), + Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh), + Layer.succeed( + ManagedRelay.ManagedRelayClient, + relayClient( + options?.connectEnvironment ?? + ((input) => + Effect.succeed({ + environmentId: input.environmentId, + endpoint: ENDPOINT, + credential: "relay-bootstrap", + expiresAt: "2026-06-06T00:00:00.000Z", + })), + ), + ), + ); + + return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies))); +}); + +describe("ConnectionResolver", () => { + it.effect("prepares a primary environment without remote capabilities", () => + Effect.gen(function* () { + const brokerLayer = yield* makeDependencies(); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const target = new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + wsBaseUrl: "ws://127.0.0.1:3777", + }); + + expect(yield* broker.prepare(catalogEntry(target))).toEqual({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + socketUrl: "ws://127.0.0.1:3777/ws", + httpAuthorization: null, + target, + }); + }), + ); + + it.effect("authorizes a desktop primary environment with its platform bearer token", () => + Effect.gen(function* () { + const bearerInputs = yield* Ref.make>([]); + const brokerLayer = yield* makeDependencies({ + primaryBearerToken: "desktop-bearer", + authorizeBearer: (input) => + Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Primary", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const target = new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + wsBaseUrl: "ws://127.0.0.1:3777", + }); + + expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({ + socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop", + httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, + target, + }); + expect(yield* Ref.get(bearerInputs)).toEqual(["desktop-bearer"]); + }), + ); + + it.effect("uses the registered bearer profile without re-reading the profile store", () => + Effect.gen(function* () { + const bearerInputs = yield* Ref.make>([]); + const target = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Saved", + connectionId: "saved-1", + }); + const profile = new BearerConnectionProfile({ + connectionId: "saved-1", + environmentId: ENVIRONMENT_ID, + label: "Saved", + httpBaseUrl: ENDPOINT.httpBaseUrl, + wsBaseUrl: ENDPOINT.wsBaseUrl, + }); + const brokerLayer = yield* makeDependencies({ + credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]], + authorizeBearer: (input) => + Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Saved", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=ticket", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect( + (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, + ).toContain("wsTicket=ticket"); + expect(yield* Ref.get(bearerInputs)).toEqual(["secret-bearer"]); + }), + ); + + it.effect("brokers relay credentials with the current cloud session and device identity", () => + Effect.gen(function* () { + const relayInputs = yield* Ref.make< + ReadonlyArray<{ + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly deviceId?: string; + }> + >([]); + const bootstrapCredentials = yield* Ref.make>([]); + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + connectEnvironment: (input) => + Ref.update(relayInputs, (values) => [ + ...values, + { + clerkToken: input.clerkToken, + scopes: input.scopes, + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + }, + ]).pipe( + Effect.as({ + environmentId: input.environmentId, + endpoint: ENDPOINT, + credential: "relay-bootstrap", + expiresAt: "2026-06-06T00:00:00.000Z", + }), + ), + authorizeDpop: (input) => + input.obtainBootstrap.pipe( + Effect.tap((bootstrap) => + Ref.update(bootstrapCredentials, (values) => [...values, bootstrap.credential]), + ), + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Cloud", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop"); + expect(yield* Ref.get(relayInputs)).toEqual([ + { + clerkToken: "clerk-session", + scopes: [RelayEnvironmentConnectScope], + deviceId: "device-1", + }, + ]); + expect(yield* Ref.get(bootstrapCredentials)).toEqual(["relay-bootstrap"]); + }), + ); + + it.effect("exports the complete relay authorization flow through the product tracer", () => + Effect.gen(function* () { + const userSpans: Array = []; + const productSpans: Array = []; + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + authorizeDpop: (input) => + input.obtainBootstrap.pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Cloud", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + Effect.withSpan("test.remote.authorizeDpop"), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + yield* broker + .prepare(catalogEntry(target)) + .pipe( + Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), + Effect.withTracer(collectingTracer(userSpans)), + ); + + expect(productSpans).toContain("clientRuntime.connection.broker.relay"); + expect(productSpans).toContain("test.remote.authorizeDpop"); + expect(userSpans).toContain("clientRuntime.connection.broker.prepare"); + expect(userSpans).not.toContain("test.remote.authorizeDpop"); + }), + ); + + it.effect("delegates SSH launch to the platform gateway before remote authorization", () => + Effect.gen(function* () { + const preparedTargets = yield* Ref.make>([]); + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: "ssh-1", + environmentId: ENVIRONMENT_ID, + label: "SSH", + target: SSH_TARGET, + }); + const brokerLayer = yield* makeDependencies({ + prepareSsh: (input) => + Ref.update(preparedTargets, (values) => [...values, input.target]).pipe( + Effect.as({ + bootstrap: { + target: input.target, + httpBaseUrl: "http://127.0.0.1:4010", + wsBaseUrl: "ws://127.0.0.1:4010", + pairingToken: null, + }, + bearerToken: "ssh-bearer", + }), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect( + (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, + ).toContain("wsTicket=bearer"); + expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]); + }), + ); + + it.effect("classifies relay request timeouts as retryable connection failures", () => + Effect.gen(function* () { + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + connectEnvironment: () => + Effect.fail( + new ManagedRelay.ManagedRelayRequestTimeoutError({ + activity: "Relay environment connection", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, + }), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ reason: "timeout" }); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts new file mode 100644 index 00000000000..c219bde092c --- /dev/null +++ b/packages/client-runtime/src/connection/resolver.ts @@ -0,0 +1,273 @@ +import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + type ConnectionCatalogEntry, + SshConnectionProfile, +} from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; +import { + credentialMissingError, + environmentMismatchError, + mapManagedRelayError, + profileMissingError, +} from "./errors.ts"; +import type { + BearerConnectionTarget, + ConnectionTarget, + PreparedConnection, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "./model.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; + +export class ConnectionResolver extends Context.Service< + ConnectionResolver, + { + readonly prepare: ( + entry: ConnectionCatalogEntry, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {} + +const isBearerProfile = Schema.is(BearerConnectionProfile); +const isSshProfile = Schema.is(SshConnectionProfile); +const isBearerCredential = Schema.is(BearerConnectionCredential); + +function primarySocketUrl(target: PrimaryConnectionTarget): string { + const url = new URL(target.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + return url.toString(); +} + +const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { + const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.primary")(function* ( + target: PrimaryConnectionTarget, + ) { + const bearerToken = yield* auth.bearerToken; + if (Option.isNone(bearerToken)) { + return { + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: target.httpBaseUrl, + socketUrl: primarySocketUrl(target), + httpAuthorization: null, + target, + } satisfies PreparedConnection; + } + + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: target.httpBaseUrl, + wsBaseUrl: target.wsBaseUrl, + bearerToken: bearerToken.value, + }); + return { + ...authorized, + target, + } satisfies PreparedConnection; + }); +}); + +const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( + entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, + ) { + const target = entry.target; + const profile = yield* Option.match(entry.profile, { + onNone: () => Effect.fail(profileMissingError(target.connectionId)), + onSome: Effect.succeed, + }); + if (!isBearerProfile(profile)) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: `Connection profile ${target.connectionId} is not a bearer connection.`, + }); + } + if (profile.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: profile.environmentId, + }); + } + const credential = yield* credentials.get(target.connectionId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(credentialMissingError(target.connectionId)), + onSome: Effect.succeed, + }), + ), + ); + if (!isBearerCredential(credential)) { + return yield* credentialMissingError(target.connectionId); + } + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: profile.httpBaseUrl, + wsBaseUrl: profile.wsBaseUrl, + bearerToken: credential.token, + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }); +}); + +const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { + const relay = yield* ManagedRelay.ManagedRelayClient; + const session = yield* ClientCapabilities.CloudSession; + const identity = yield* ClientCapabilities.RelayDeviceIdentity; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + + return Effect.fnUntraced( + function* (target: RelayConnectionTarget) { + const authorized = yield* remote.authorizeDpop({ + expectedEnvironmentId: target.environmentId, + obtainBootstrap: Effect.gen(function* () { + const clerkToken = yield* session.clerkToken.pipe( + Effect.withSpan("relay.connection.cloudSessionToken.resolve"), + ); + const deviceId = yield* identity.deviceId.pipe( + Effect.withSpan("relay.connection.deviceIdentity.resolve"), + ); + const connected = yield* relay + .connectEnvironment({ + clerkToken, + scopes: [RelayEnvironmentConnectScope], + environmentId: target.environmentId, + ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}), + }) + .pipe(Effect.mapError(mapManagedRelayError)); + if (connected.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: connected.environmentId, + }); + } + return connected; + }).pipe(Effect.withSpan("relay.connection.bootstrap.obtain")), + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }, + Effect.withSpan("clientRuntime.connection.broker.relay"), + withRelayClientTracing, + ); +}); + +const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { + const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.ssh")(function* ( + entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget }, + ) { + const target = entry.target; + const profile = yield* Option.match(entry.profile, { + onNone: () => Effect.fail(profileMissingError(target.connectionId)), + onSome: Effect.succeed, + }); + if (!isSshProfile(profile)) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: `Connection profile ${target.connectionId} is not an SSH connection.`, + }); + } + if (profile.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: profile.environmentId, + }); + } + const prepared = yield* ssh.prepare({ + connectionId: target.connectionId, + expectedEnvironmentId: target.environmentId, + target: profile.target, + }); + yield* profiles.put( + new SshConnectionProfile({ + connectionId: profile.connectionId, + environmentId: profile.environmentId, + label: profile.label, + target: prepared.bootstrap.target, + }), + ); + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: prepared.bootstrap.httpBaseUrl, + wsBaseUrl: prepared.bootstrap.wsBaseUrl, + bearerToken: prepared.bearerToken, + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }); +}); + +export const make = Effect.gen(function* () { + const primary = yield* makePrimaryBroker(); + const bearer = yield* makeBearerBroker(); + const relay = yield* makeRelayBroker(); + const ssh = yield* makeSshBroker(); + + const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + entry: ConnectionCatalogEntry, + ) { + const target: ConnectionTarget = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, + }); + switch (target._tag) { + case "PrimaryConnectionTarget": + return yield* primary(target); + case "BearerConnectionTarget": + return yield* bearer({ ...entry, target }); + case "RelayConnectionTarget": + return yield* relay(target); + case "SshConnectionTarget": + return yield* ssh({ ...entry, target }); + } + }); + + return ConnectionResolver.of({ prepare }); +}); + +export const layer = Layer.effect(ConnectionResolver, make); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts new file mode 100644 index 00000000000..eadeceacc2c --- /dev/null +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -0,0 +1,846 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionDriver from "./driver.ts"; +import { + ConnectionBlockedError, + ConnectionTransientError, + PrimaryConnectionTarget, + RelayConnectionTarget, + type ConnectionAttemptError, + type ConnectionTarget, + type NetworkStatus, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import * as RpcSession from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const RELAY_TARGET = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, +}); + +const TARGET_ENTRY: ConnectionCatalogEntry = { + target: TARGET, + profile: Option.none(), +}; + +const RELAY_ENTRY: ConnectionCatalogEntry = { + target: RELAY_TARGET, + profile: Option.none(), +}; + +const PREPARED_CONNECTION: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: null, + target: TARGET, +}; + +const TEST_RPC_CLIENT = {} as WsRpcProtocolClient; + +function transient(message = "Connection failed.") { + return new ConnectionTransientError({ + reason: "transport", + detail: message, + }); +} + +function blocked(message = "Authentication required.") { + return new ConnectionBlockedError({ + reason: "authentication", + detail: message, + }); +} + +function awaitState( + state: SubscriptionRef.SubscriptionRef, + predicate: (value: SupervisorConnectionState) => boolean, +) { + return SubscriptionRef.changes(state).pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); +} + +const eventuallyState = Effect.fn("TestConnectionHarness.eventuallyState")(function* ( + state: SubscriptionRef.SubscriptionRef, + predicate: (value: SupervisorConnectionState) => boolean, +) { + let lastState = yield* SubscriptionRef.get(state); + for (let iteration = 0; iteration < 100; iteration += 1) { + lastState = yield* SubscriptionRef.get(state); + if (predicate(lastState)) { + return lastState; + } + yield* Effect.yieldNow; + } + return yield* Effect.die( + new Error( + `Expected supervisor state was not observed. Last state: phase=${lastState.phase}, stage=${lastState.stage ?? "none"}, attempt=${lastState.attempt}, generation=${lastState.generation}`, + ), + ); +}); + +const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: { + readonly networkStatus?: NetworkStatus; + readonly prepare?: ( + attempt: number, + target: ConnectionTarget, + ) => Effect.Effect; + readonly ready?: (attempt: number) => Effect.Effect; + readonly probe?: (attempt: number) => Effect.Effect; +}) { + const networkStatus = yield* SubscriptionRef.make( + options?.networkStatus ?? "online", + ); + const prepareCount = yield* Ref.make(0); + const sessionCount = yield* Ref.make(0); + const releaseCount = yield* Ref.make(0); + const wakeups = yield* SubscriptionRef.make<{ + readonly sequence: number; + readonly reason: "application-active" | "credentials-changed"; + }>({ + sequence: 0, + reason: "application-active", + }); + const closedSessions = yield* Ref.make< + ReadonlyArray> + >([]); + + const connectivity = Connectivity.Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + + const prepare = Effect.fn("TestConnectionDriver.prepare")(function* (target: ConnectionTarget) { + const attempt = yield* Ref.updateAndGet(prepareCount, (count) => count + 1); + if (options?.prepare) { + return yield* options.prepare(attempt, target); + } + return PREPARED_CONNECTION; + }); + + const connect = Effect.fn("TestConnectionDriver.connect")(function* ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect, + ) { + const target = entry.target; + yield* reportProgress({ stage: "preparing" }); + const prepared = yield* prepare(target); + yield* reportProgress({ stage: "opening", prepared }); + + const attempt = yield* Ref.updateAndGet(sessionCount, (count) => count + 1); + const closed = yield* Deferred.make(); + yield* Ref.update(closedSessions, (sessions) => [...sessions, closed]); + + const session = yield* Effect.acquireRelease( + Effect.succeed({ + client: TEST_RPC_CLIENT, + initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + ready: options?.ready?.(attempt) ?? Effect.void, + probe: options?.probe?.(attempt) ?? Effect.void, + closed: Deferred.await(closed), + } satisfies RpcSession.RpcSession), + () => Ref.update(releaseCount, (count) => count + 1), + ); + + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease; + }); + + const dependencies = Layer.mergeAll( + Layer.succeed(Connectivity.Connectivity, connectivity), + Layer.succeed( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ + changes: SubscriptionRef.changes(wakeups).pipe( + Stream.drop(1), + Stream.map((event) => event.reason), + ), + }), + ), + Layer.succeed( + ConnectionDriver.ConnectionDriver, + ConnectionDriver.ConnectionDriver.of({ connect }), + ), + ); + + return { + dependencies, + prepareCount, + sessionCount, + releaseCount, + setNetworkStatus: (status: NetworkStatus) => SubscriptionRef.set(networkStatus, status), + wake: (reason: "application-active" | "credentials-changed") => + SubscriptionRef.update(wakeups, (event) => ({ + sequence: event.sequence + 1, + reason, + })), + closeLatestSession: Effect.fn("TestConnectionHarness.closeLatestSession")(function* ( + error = transient("Session closed."), + ) { + const sessions = yield* Ref.get(closedSessions); + const latest = sessions.at(-1); + if (latest) { + yield* Deferred.fail(latest, error); + } + }), + }; +}); + +describe("EnvironmentSupervisor", () => { + it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + spans.push(span); + }; + return span; + }, + }); + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe( + Effect.provide(harness.dependencies), + Effect.provideService(RelayClientTracer, Option.some(tracer)), + ); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt"); + expect(firstAttempt).toBeDefined(); + + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + const attempts = spans.filter((span) => span.name === "relay.connection.attempt"); + expect(attempts).toHaveLength(2); + expect(attempts[0]?.traceId).not.toBe(attempts[1]?.traceId); + expect(attempts[1]?.links.map((link) => link.span.spanId)).toContain(attempts[0]?.spanId); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not attempt a connection until it is desired", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe( + Effect.provide(harness.dependencies), + ); + + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("available"); + expect(yield* Ref.get(harness.prepareCount)).toBe(0); + }), + ); + + it.effect("does not let the initial connect signal cancel the first attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe( + Effect.provide(harness.dependencies), + ); + + yield* supervisor.connect; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + }), + ); + + it.effect("waits while offline and connects immediately when the network returns", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ networkStatus: "offline" }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + expect(yield* Ref.get(harness.prepareCount)).toBe(0); + + yield* harness.setNetworkStatus("online"); + const ready = yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(ready).toMatchObject({ + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + lastFailure: null, + }); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + }), + ); + + it.effect("retries forever with exponential backoff capped at sixteen seconds", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.fail(transient()), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + yield* TestClock.adjust(delay); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === index + 2, + ); + } + + expect(yield* Ref.get(harness.prepareCount)).toBe(7); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps the latest failure visible throughout the next connection attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient("Relay connection timed out.")) : Effect.never, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("1 second"); + + const retrying = yield* awaitState( + supervisor.state, + (state) => + state.phase === "connecting" && state.stage === "preparing" && state.attempt === 2, + ); + expect(retrying).toMatchObject({ + phase: "connecting", + stage: "preparing", + attempt: 2, + lastFailure: { + _tag: "ConnectionTransientError", + reason: "transport", + message: "Relay connection timed out.", + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("retries when a session never becomes ready", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + ready: () => Effect.never, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "synchronizing", + ); + yield* TestClock.adjust("14 seconds"); + expect((yield* SubscriptionRef.get(supervisor.state)).stage).toBe("synchronizing"); + + yield* TestClock.adjust("1 second"); + const retrying = yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + + expect(retrying).toMatchObject({ + phase: "backoff", + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("interrupts and releases a connection attempt when setup times out", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.never, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "preparing", + ); + yield* TestClock.adjust("15 seconds"); + const retrying = yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + expect(retrying).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("converts unexpected driver defects into retryable failures", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 + ? Effect.die(new Error("Native transport defect.")) + : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + const failed = yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(failed).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "transport", + message: "Test environment connection failed unexpectedly.", + }, + }); + + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("explicit retry interrupts the current backoff", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + yield* supervisor.retryNow; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }), + ); + + it.effect("keeps blocked failures idle until an external signal requests another attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "blocked"); + yield* TestClock.adjust("1 hour"); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + yield* supervisor.retryNow; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("releases a live session while offline and starts a new generation when online", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* harness.setNetworkStatus("offline"); + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + + yield* harness.setNetworkStatus("online"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }), + ); + + it.effect("retries a blocked connection when platform credentials change", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "blocked"); + yield* harness.wake("credentials-changed"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }), + ); + + it.effect("does not let platform wakeups reset an in-flight attempt", () => + Effect.gen(function* () { + const firstAttemptStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + prepare: () => + Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(firstAttemptStarted); + yield* Effect.all( + [ + harness.wake("credentials-changed"), + harness.wake("application-active"), + harness.wake("credentials-changed"), + ], + { concurrency: "unbounded" }, + ); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + yield* TestClock.adjust("15 seconds"); + const retrying = yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + expect(retrying).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("treats an involuntary session close as transient and reconnects", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(Option.isSome(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps escalating backoff when a newly opened session flaps", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + yield* harness.closeLatestSession(); + const secondFailure = yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + + expect(secondFailure.retryAt).not.toBeNull(); + + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 3, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps a healthy session when the application becomes active", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const probeCalled = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: () => + Ref.update(probeCount, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(probeCalled, undefined)), + ), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* Deferred.await(probeCalled); + + expect(yield* Ref.get(probeCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + }), + ); + + it.effect("reconnects when the foreground liveness probe fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => + attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + yield* TestClock.adjust("1 second"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("times out a stalled foreground liveness probe and reconnects", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("15 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", + ); + yield* TestClock.adjust("1 second"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("honors an explicit disconnect while a foreground probe is stalled", () => + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: () => Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* Deferred.await(probeStarted); + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect("does not churn a healthy session when credentials change", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("credentials-changed"); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + }), + ); + + it.effect("releases and reconnects a relay session when credentials change", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("credentials-changed"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect("interrupts relay setup when credentials change", () => + Effect.gen(function* () { + const firstAttemptStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 + ? Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(firstAttemptStarted); + yield* harness.wake("credentials-changed"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + }), + ); + + it.effect("explicit disconnect releases the session and returns to available", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }), + ); + + it.effect("does not lose an explicit disconnect among concurrent wakeup signals", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* Effect.all( + [ + supervisor.disconnect, + harness.wake("credentials-changed"), + harness.wake("application-active"), + harness.wake("credentials-changed"), + ], + { concurrency: "unbounded" }, + ); + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts new file mode 100644 index 00000000000..d9efcd4263a --- /dev/null +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -0,0 +1,730 @@ +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as Tracer from "effect/Tracer"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionDriver from "./driver.ts"; +import { + type ConnectionAttemptError, + type ConnectionTarget, + ConnectionTransientError, + type NetworkStatus, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import * as RpcSession from "../rpc/session.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; + +const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; +const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; +const CONNECTION_PROBE_TIMEOUT = "15 seconds"; +const BACKOFF_RESET_AFTER_MS = 30_000; + +interface SupervisorIntent { + readonly desired: boolean; + readonly network: NetworkStatus; +} + +type SupervisorSignal = + | { readonly _tag: "ConnectRequested" } + | { readonly _tag: "DisconnectRequested" } + | { readonly _tag: "RetryRequested" } + | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } + | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup }; + +interface PendingRetryTrace { + readonly previousAttempt: Tracer.Span; + readonly failureCount: number; + readonly delayMs: number; + readonly reason: ConnectionAttemptError["reason"]; +} + +interface TracedAttemptFailure { + readonly error: ConnectionAttemptError; + readonly attemptSpan: Option.Option; +} + +type AttemptOutcome = + | { + readonly _tag: "Interrupted"; + readonly established: boolean; + readonly stable: boolean; + } + | { + readonly _tag: "Failure"; + readonly established: boolean; + readonly stable: boolean; + readonly failure: TracedAttemptFailure; + }; + +type EstablishmentEvent = + | { + readonly _tag: "Completed"; + readonly exit: Exit.Exit< + { + readonly attemptSpan: Option.Option; + readonly lease: ConnectionDriver.EnvironmentConnectionLease; + }, + TracedAttemptFailure + >; + } + | { readonly _tag: "Interrupted" } + | { readonly _tag: "TimedOut" }; + +function exitUnlessInterrupted( + effect: Effect.Effect, +): Effect.Effect, never, R> { + return Effect.matchCauseEffect(effect, { + onFailure: (cause) => + Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(Exit.failCause(cause)), + onSuccess: (value) => Effect.succeed(Exit.succeed(value)), + }); +} + +export interface EnvironmentSupervisorOptions { + readonly initiallyDesired?: boolean; +} + +function retryDelayMs(failureCount: number): number { + return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; +} + +function annotateTarget(target: ConnectionTarget) { + return Effect.annotateCurrentSpan({ + "environment.id": target.environmentId, + "environment.label": target.label, + "environment.target.kind": target._tag, + }); +} + +function availableState(intent: SupervisorIntent, generation: number): SupervisorConnectionState { + return { + desired: false, + network: intent.network, + phase: "available", + stage: null, + attempt: 0, + generation, + lastFailure: null, + retryAt: null, + }; +} + +function offlineState( + intent: SupervisorIntent, + generation: number, + attempt: number, + lastFailure: ConnectionAttemptError | null, +): SupervisorConnectionState { + return { + desired: true, + network: intent.network, + phase: "offline", + stage: null, + attempt, + generation, + lastFailure, + retryAt: null, + }; +} + +function connectingState( + intent: SupervisorIntent, + generation: number, + attempt: number, + lastFailure: ConnectionAttemptError | null, + stage: SupervisorConnectionState["stage"] = "preparing", +): SupervisorConnectionState { + return { + desired: true, + network: intent.network, + phase: "connecting", + stage, + attempt, + generation, + lastFailure, + retryAt: null, + }; +} + +function failureFromExit( + target: ConnectionTarget, + exit: Exit.Exit, + established: boolean, + stable: boolean, +): AttemptOutcome { + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { + return { _tag: "Interrupted", established, stable }; + } + const typedFailure = exit.cause.reasons.find(Cause.isFailReason); + if (typedFailure) { + return { + _tag: "Failure", + established, + stable, + failure: typedFailure.error, + }; + } + return { + _tag: "Failure", + established, + stable, + failure: { + error: new ConnectionTransientError({ + reason: "transport", + detail: `${target.label} connection failed unexpectedly.`, + }), + attemptSpan: Option.none(), + }, + }; +} + +export class EnvironmentSupervisor extends Context.Service< + EnvironmentSupervisor, + { + readonly target: ConnectionTarget; + readonly state: SubscriptionRef.SubscriptionRef; + readonly session: SubscriptionRef.SubscriptionRef>; + readonly prepared: SubscriptionRef.SubscriptionRef>; + readonly connect: Effect.Effect; + readonly disconnect: Effect.Effect; + readonly retryNow: Effect.Effect; + } +>()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {} + +export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( + entry: ConnectionCatalogEntry, + options?: EnvironmentSupervisorOptions, +): Effect.fn.Return< + EnvironmentSupervisor["Service"], + never, + | Connectivity.Connectivity + | ConnectionDriver.ConnectionDriver + | Scope.Scope + | ConnectionWakeups.ConnectionWakeups +> { + const target = entry.target; + yield* annotateTarget(target); + + const connectivity = yield* Connectivity.Connectivity; + const driver = yield* ConnectionDriver.ConnectionDriver; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + const initialIntent: SupervisorIntent = { + desired: options?.initiallyDesired ?? false, + network: yield* connectivity.status, + }; + const intent = yield* Ref.make(initialIntent); + const signals = yield* Queue.unbounded(); + const state = yield* SubscriptionRef.make( + !initialIntent.desired + ? availableState(initialIntent, 0) + : initialIntent.network === "offline" + ? offlineState(initialIntent, 0, 0, null) + : connectingState(initialIntent, 0, 1, null), + ); + const session = yield* SubscriptionRef.make>(Option.none()); + const prepared = yield* SubscriptionRef.make>(Option.none()); + + const clearLease = Effect.all( + [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())], + { discard: true }, + ); + + const setState = Effect.fn("EnvironmentSupervisor.setState")(function* ( + next: SupervisorConnectionState, + ) { + yield* SubscriptionRef.set(state, next); + }); + + const signal = Effect.fn("EnvironmentSupervisor.signal")(function* (next: SupervisorSignal) { + yield* Queue.offer(signals, next); + }); + + const logManagedRelayAccountChange = Effect.logInfo( + "Managed relay account changed; restarting the environment connection.", + ).pipe( + Effect.annotateLogs({ + "environment.id": target.environmentId, + "environment.label": target.label, + }), + ); + + const reportProgress = Effect.fn("EnvironmentSupervisor.reportProgress")(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + progress: ConnectionDriver.ConnectionDriverProgress, + ) { + if ("prepared" in progress) { + yield* SubscriptionRef.set(prepared, Option.some(progress.prepared)); + } + yield* setState( + connectingState(yield* Ref.get(intent), generation, attempt, lastFailure, progress.stage), + ); + }); + + const establishConnection = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + ) { + return yield* driver.connect(entry, (progress) => + reportProgress(attempt, generation, lastFailure, progress), + ); + }); + + const traceRelayEstablishment = ( + effect: Effect.Effect< + ConnectionDriver.EnvironmentConnectionLease, + ConnectionAttemptError, + Scope.Scope + >, + attempt: number, + generation: number, + pendingRetry: Option.Option, + ) => { + const traced = Effect.gen(function* () { + const attemptSpan = yield* Effect.currentSpan.pipe(Effect.orDie); + yield* annotateTarget(target); + yield* Effect.annotateCurrentSpan({ + "connection.attempt": attempt, + "connection.generation": generation, + "connection.retry.failure_count": Option.match(pendingRetry, { + onNone: () => 0, + onSome: (retry) => retry.failureCount, + }), + }); + const lease = yield* effect.pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: Option.some(attemptSpan), + }), + ), + ); + return { attemptSpan: Option.some(attemptSpan), lease }; + }).pipe(Effect.withSpan("relay.connection.attempt", { root: true })); + + return Option.match(pendingRetry, { + onNone: () => traced, + onSome: (retry) => + traced.pipe( + Effect.linkSpans(retry.previousAttempt, { + "connection.retry.delay_ms": retry.delayMs, + "connection.retry.reason": retry.reason, + }), + ), + }).pipe(withRelayClientTracing); + }; + + const establishTracedConnection = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + pendingRetry: Option.Option, + ) { + if (target._tag === "RelayConnectionTarget") { + return yield* traceRelayEstablishment( + establishConnection(attempt, generation, lastFailure), + attempt, + generation, + pendingRetry, + ); + } + return yield* establishConnection(attempt, generation, lastFailure).pipe( + Effect.map((lease) => ({ + attemptSpan: Option.none(), + lease, + })), + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: Option.none(), + }), + ), + ); + }); + + const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "DisconnectRequested": + case "RetryRequested": + return; + case "NetworkChanged": + if (next.network === "offline") { + return; + } + break; + case "ConnectRequested": + break; + case "Wakeup": + if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { + yield* logManagedRelayAccountChange; + return; + } + break; + } + } + }); + + const monitorConnectedLease = Effect.fnUntraced(function* ( + lease: ConnectionDriver.EnvironmentConnectionLease, + ) { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "DisconnectRequested": + case "RetryRequested": + return; + case "NetworkChanged": + if (next.network === "offline") { + return; + } + break; + case "Wakeup": + if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { + yield* logManagedRelayAccountChange; + return; + } + if (next.reason === "application-active") { + const probe = yield* lease.session.probe.pipe( + Effect.timeoutOrElse({ + duration: CONNECTION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new ConnectionTransientError({ + reason: "timeout", + detail: `${target.label} did not respond to a connection health check.`, + }), + ), + }), + Effect.forkChild, + ); + for (;;) { + const probeEvent = yield* Effect.raceFirst( + Fiber.await(probe).pipe( + Effect.map((exit) => ({ _tag: "ProbeCompleted" as const, exit })), + ), + Queue.take(signals).pipe( + Effect.map((signal) => ({ _tag: "Signal" as const, signal })), + ), + ); + if (probeEvent._tag === "ProbeCompleted") { + yield* probeEvent.exit; + break; + } + switch (probeEvent.signal._tag) { + case "DisconnectRequested": + case "RetryRequested": + yield* Fiber.interrupt(probe); + return; + case "NetworkChanged": + if (probeEvent.signal.network === "offline") { + yield* Fiber.interrupt(probe); + return; + } + break; + case "ConnectRequested": + case "Wakeup": + break; + } + } + } + break; + case "ConnectRequested": + break; + } + } + }); + + const runAttempt = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + pendingRetry: Option.Option, + ) { + yield* SubscriptionRef.set(prepared, Option.none()); + const establishment = yield* Effect.raceAllFirst([ + exitUnlessInterrupted( + establishTracedConnection(attempt, generation, lastFailure, pendingRetry), + ).pipe( + Effect.map( + (exit): EstablishmentEvent => ({ + _tag: "Completed", + exit, + }), + ), + ), + waitForEstablishmentInterrupt().pipe(Effect.as({ _tag: "Interrupted" })), + Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( + Effect.as({ _tag: "TimedOut" }), + ), + ]); + + if (establishment._tag === "Interrupted") { + return { + _tag: "Interrupted", + established: false, + stable: false, + } satisfies AttemptOutcome; + } + if (establishment._tag === "TimedOut") { + return { + _tag: "Failure", + established: false, + stable: false, + failure: { + error: new ConnectionTransientError({ + reason: "timeout", + detail: `${target.label} did not respond during connection setup.`, + }), + attemptSpan: Option.none(), + }, + } satisfies AttemptOutcome; + } + if (Exit.isFailure(establishment.exit)) { + const isUnexpectedDefect = + !Cause.hasInterruptsOnly(establishment.exit.cause) && + !establishment.exit.cause.reasons.some(Cause.isFailReason); + const outcome = failureFromExit(target, establishment.exit, false, false); + if (isUnexpectedDefect) { + const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect; + yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( + Effect.annotateLogs({ + "environment.id": target.environmentId, + "environment.label": target.label, + "cause.reason_count": establishment.exit.cause.reasons.length, + ...safeErrorLogAttributes(defect), + }), + ); + } + return outcome; + } + + const active = establishment.exit.value; + const currentIntent = yield* Ref.get(intent); + if (!currentIntent.desired || currentIntent.network === "offline") { + return { + _tag: "Interrupted", + established: false, + stable: false, + } satisfies AttemptOutcome; + } + + const connectedAt = yield* Clock.currentTimeMillis; + yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); + yield* SubscriptionRef.set(session, Option.some(active.lease.session)); + yield* setState({ + desired: true, + network: currentIntent.network, + phase: "connected", + stage: null, + attempt, + generation, + lastFailure: null, + retryAt: null, + }); + + const connectedExit = yield* Effect.raceFirst( + active.lease.session.closed.pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + }), + ), + ), + monitorConnectedLease(active.lease).pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + }), + ), + ), + ).pipe(exitUnlessInterrupted); + const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; + return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS); + }, Effect.ensuring(clearLease)); + + const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) { + return yield* Effect.raceFirst( + Effect.sleep(delayMs), + Effect.gen(function* () { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "ConnectRequested": + case "DisconnectRequested": + case "RetryRequested": + case "NetworkChanged": + case "Wakeup": + return; + } + } + }), + ); + }); + + const waitForSignal = Queue.take(signals); + + const run = Effect.fnUntraced(function* () { + let failureCount = 0; + let generation = 0; + let latestFailure: ConnectionAttemptError | null = null; + let pendingRetry = Option.none(); + + for (;;) { + const currentIntent = yield* Ref.get(intent); + if (!currentIntent.desired) { + failureCount = 0; + latestFailure = null; + pendingRetry = Option.none(); + yield* clearLease; + yield* setState(availableState(currentIntent, generation)); + yield* waitForSignal; + continue; + } + if (currentIntent.network === "offline") { + yield* clearLease; + yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure)); + yield* waitForSignal; + continue; + } + + const attempt = failureCount + 1; + const nextGeneration = generation + 1; + const outcome: AttemptOutcome = yield* Effect.scoped( + runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), + ); + if (outcome.established) { + generation = nextGeneration; + if (outcome.stable) { + failureCount = 0; + latestFailure = null; + pendingRetry = Option.none(); + } + } + if (outcome._tag === "Interrupted") { + continue; + } + + const attemptSpan: Option.Option = outcome.failure.attemptSpan; + const error: ConnectionAttemptError = outcome.failure.error; + latestFailure = error; + if (error._tag === "ConnectionBlockedError") { + const blockedIntent = yield* Ref.get(intent); + yield* setState({ + desired: blockedIntent.desired, + network: blockedIntent.network, + phase: "blocked", + stage: null, + attempt, + generation, + lastFailure: error, + retryAt: null, + }); + yield* waitForSignal; + continue; + } + + failureCount += 1; + const delayMs = retryDelayMs(failureCount - 1); + pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ + previousAttempt, + failureCount, + delayMs, + reason: error.reason, + })); + const failedIntent = yield* Ref.get(intent); + yield* setState({ + desired: failedIntent.desired, + network: failedIntent.network, + phase: "backoff", + stage: null, + attempt, + generation, + lastFailure: error, + retryAt: (yield* Clock.currentTimeMillis) + delayMs, + }); + yield* waitForRetrySignal(delayMs); + } + }); + + yield* connectivity.changes.pipe( + Stream.runForEach((network) => + Ref.modify(intent, (current) => + current.network === network ? [false, current] : ([true, { ...current, network }] as const), + ).pipe( + Effect.flatMap((changed) => + changed ? signal({ _tag: "NetworkChanged", network }) : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* wakeups.changes.pipe( + Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })), + Effect.forkScoped, + ); + yield* run().pipe(Effect.forkScoped); + + const connect = Ref.update(intent, (current) => ({ + ...current, + desired: true, + })).pipe( + Effect.andThen(signal({ _tag: "ConnectRequested" })), + Effect.withSpan("EnvironmentSupervisor.connect"), + ); + + const disconnect = Ref.update(intent, (current) => ({ + ...current, + desired: false, + })).pipe( + Effect.andThen(signal({ _tag: "DisconnectRequested" })), + Effect.withSpan("EnvironmentSupervisor.disconnect"), + ); + + const retryNow = signal({ _tag: "RetryRequested" }).pipe( + Effect.withSpan("EnvironmentSupervisor.retryNow"), + ); + + yield* Effect.addFinalizer(() => Queue.shutdown(signals).pipe(Effect.andThen(clearLease))); + + return EnvironmentSupervisor.of({ + target, + state, + session, + prepared, + connect, + disconnect, + retryNow, + }); +}); + +export const layer = ( + entry: ConnectionCatalogEntry, + options?: EnvironmentSupervisorOptions, +): Layer.Layer< + EnvironmentSupervisor, + never, + | Connectivity.Connectivity + | ConnectionDriver.ConnectionDriver + | ConnectionWakeups.ConnectionWakeups +> => Layer.effect(EnvironmentSupervisor, make(entry, options)); diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts new file mode 100644 index 00000000000..107c5983e02 --- /dev/null +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -0,0 +1,17 @@ +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import type * as Stream from "effect/Stream"; + +export type ConnectionWakeup = "application-active" | "credentials-changed"; + +export class ConnectionWakeups extends Context.Service< + ConnectionWakeups, + { + readonly changes: Stream.Stream; + } +>()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} + +export const make = (service: ConnectionWakeups["Service"]) => ConnectionWakeups.of(service); + +export const layer = (service: ConnectionWakeups["Service"]) => + Layer.succeed(ConnectionWakeups, make(service)); diff --git a/packages/client-runtime/src/environment/descriptor.ts b/packages/client-runtime/src/environment/descriptor.ts new file mode 100644 index 00000000000..d49a0d9a890 --- /dev/null +++ b/packages/client-runtime/src/environment/descriptor.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; + +import { environmentEndpointUrl } from "./endpoint.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; + +const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; + +export const fetchRemoteEnvironmentDescriptor = Effect.fn( + "clientRuntime.environment.fetchRemoteEnvironmentDescriptor", +)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.metadata.descriptor(), + ); +}); diff --git a/packages/client-runtime/src/advertisedEndpoint.test.ts b/packages/client-runtime/src/environment/endpoint.test.ts similarity index 98% rename from packages/client-runtime/src/advertisedEndpoint.test.ts rename to packages/client-runtime/src/environment/endpoint.test.ts index f158e313fec..ef45a061810 100644 --- a/packages/client-runtime/src/advertisedEndpoint.test.ts +++ b/packages/client-runtime/src/environment/endpoint.test.ts @@ -6,7 +6,7 @@ import { createAdvertisedEndpoint, deriveWsBaseUrl, normalizeHttpBaseUrl, -} from "./advertisedEndpoint.ts"; +} from "./endpoint.ts"; const coreProvider = { id: "desktop-core", diff --git a/packages/client-runtime/src/environment/endpoint.ts b/packages/client-runtime/src/environment/endpoint.ts new file mode 100644 index 00000000000..4178259361e --- /dev/null +++ b/packages/client-runtime/src/environment/endpoint.ts @@ -0,0 +1,9 @@ +export * from "@t3tools/shared/advertisedEndpoint"; + +export const environmentEndpointUrl = (httpBaseUrl: string, pathname: string): string => { + const url = new URL(httpBaseUrl); + url.pathname = pathname; + url.search = ""; + url.hash = ""; + return url.toString(); +}; diff --git a/packages/client-runtime/src/environment/index.ts b/packages/client-runtime/src/environment/index.ts new file mode 100644 index 00000000000..03c6bf6e491 --- /dev/null +++ b/packages/client-runtime/src/environment/index.ts @@ -0,0 +1,4 @@ +export * from "./descriptor.ts"; +export * from "./endpoint.ts"; +export * from "./knownEnvironment.ts"; +export * from "./scoped.ts"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/environment/knownEnvironment.test.ts similarity index 72% rename from packages/client-runtime/src/knownEnvironment.test.ts rename to packages/client-runtime/src/environment/knownEnvironment.test.ts index cb96ab2417e..66bbb1df7e9 100644 --- a/packages/client-runtime/src/knownEnvironment.test.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment.ts"; +import { createKnownEnvironment } from "./knownEnvironment.ts"; import { parseScopedProjectKey, parseScopedThreadKey, @@ -32,32 +32,6 @@ describe("known environment bootstrap helpers", () => { }, }); }); - - it("returns the explicit fetchable http origin", () => { - expect( - getKnownEnvironmentHttpBaseUrl( - createKnownEnvironment({ - label: "Local environment", - target: { - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - }, - }), - ), - ).toBe("http://localhost:3773"); - - expect( - getKnownEnvironmentHttpBaseUrl( - createKnownEnvironment({ - label: "Remote environment", - target: { - httpBaseUrl: "https://remote.example.com/api", - wsBaseUrl: "wss://remote.example.com/api", - }, - }), - ), - ).toBe("https://remote.example.com/api"); - }); }); describe("scoped refs", () => { diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/environment/knownEnvironment.ts similarity index 77% rename from packages/client-runtime/src/knownEnvironment.ts rename to packages/client-runtime/src/environment/knownEnvironment.ts index 495a6ddc9a7..42d3c8fbeb1 100644 --- a/packages/client-runtime/src/knownEnvironment.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.ts @@ -29,18 +29,6 @@ export function createKnownEnvironment(input: { }; } -export function getKnownEnvironmentWsBaseUrl( - environment: KnownEnvironment | null | undefined, -): string | null { - return environment?.target.wsBaseUrl ?? null; -} - -export function getKnownEnvironmentHttpBaseUrl( - environment: KnownEnvironment | null | undefined, -): string | null { - return environment?.target.httpBaseUrl ?? null; -} - export function attachEnvironmentDescriptor( environment: KnownEnvironment, descriptor: ExecutionEnvironmentDescriptor, diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/environment/scoped.ts similarity index 100% rename from packages/client-runtime/src/scoped.ts rename to packages/client-runtime/src/environment/scoped.ts diff --git a/packages/client-runtime/src/environmentConnection.ts b/packages/client-runtime/src/environmentConnection.ts deleted file mode 100644 index 636b1808595..00000000000 --- a/packages/client-runtime/src/environmentConnection.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { - EnvironmentId, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, - ServerConfig, - ServerLifecycleWelcomePayload, - TerminalEvent, -} from "@t3tools/contracts"; - -import type { KnownEnvironment } from "./knownEnvironment.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface EnvironmentConnection { - readonly kind: "primary" | "saved"; - readonly environmentId: EnvironmentId; - readonly knownEnvironment: KnownEnvironment; - readonly client: WsRpcClient; - readonly ensureBootstrapped: () => Promise; - readonly reconnect: () => Promise; - readonly dispose: () => Promise; -} - -interface OrchestrationHandlers { - readonly applyShellEvent: ( - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, - ) => void; - readonly syncShellSnapshot: ( - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, - ) => void; - readonly applyTerminalEvent?: (event: TerminalEvent, environmentId: EnvironmentId) => void; -} - -export interface EnvironmentConnectionInput extends OrchestrationHandlers { - readonly kind: "primary" | "saved"; - readonly knownEnvironment: KnownEnvironment; - readonly client: WsRpcClient; - readonly refreshMetadata?: () => Promise; - readonly onConfigSnapshot?: (config: ServerConfig) => void; - readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; - readonly onShellResubscribe?: (environmentId: EnvironmentId) => void; -} - -export interface EnvironmentConnectionAttempt { - readonly environmentId: EnvironmentId; - readonly isCurrent: () => boolean; -} - -export class EnvironmentConnectionAttemptCancelledError extends Error { - constructor(environmentId: EnvironmentId) { - super(`Environment connection attempt ${environmentId} was cancelled.`); - this.name = "EnvironmentConnectionAttemptCancelledError"; - } -} - -export function createEnvironmentConnectionAttemptRegistry() { - const attempts = new Map(); - - return { - begin: (environmentId: EnvironmentId): EnvironmentConnectionAttempt => { - const id = Symbol(environmentId); - attempts.set(environmentId, id); - return { - environmentId, - isCurrent: () => attempts.get(environmentId) === id, - }; - }, - cancel: (environmentId: EnvironmentId): void => { - attempts.delete(environmentId); - }, - clear: (): void => { - attempts.clear(); - }, - }; -} - -export class EnvironmentConnectionDisposedError extends Error { - constructor(environmentId: EnvironmentId) { - super(`Environment connection ${environmentId} was disposed before it finished bootstrapping.`); - this.name = "EnvironmentConnectionDisposedError"; - } -} - -function createBootstrapGate() { - let resolve: (() => void) | null = null; - let reject: ((error: unknown) => void) | null = null; - const makePromise = () => { - const nextPromise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve; - reject = nextReject; - }); - void nextPromise.catch(() => undefined); - return nextPromise; - }; - let promise = makePromise(); - - return { - wait: () => promise, - resolve: () => { - resolve?.(); - resolve = null; - reject = null; - }, - reject: (error: unknown) => { - reject?.(error); - resolve = null; - reject = null; - }, - reset: () => { - promise = makePromise(); - }, - }; -} - -export function createEnvironmentConnection( - input: EnvironmentConnectionInput, -): EnvironmentConnection { - const environmentId = input.knownEnvironment.environmentId; - - if (!environmentId) { - throw new Error( - `Known environment ${input.knownEnvironment.label} is missing its environmentId.`, - ); - } - - let disposed = false; - const bootstrapGate = createBootstrapGate(); - const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined; - const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined; - - const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { - if (environmentId !== nextEnvironmentId) { - throw new Error( - `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`, - ); - } - }; - - const unsubLifecycle = shouldObserveLifecycle - ? input.client.server.subscribeLifecycle((event) => { - if (disposed || event.type !== "welcome") { - return; - } - - observeEnvironmentIdentity( - event.payload.environment.environmentId, - "server lifecycle welcome", - ); - input.onWelcome?.(event.payload); - }) - : () => undefined; - - const unsubConfig = shouldObserveConfig - ? input.client.server.subscribeConfig((event) => { - if (disposed || event.type !== "snapshot") { - return; - } - - observeEnvironmentIdentity( - event.config.environment.environmentId, - "server config snapshot", - ); - input.onConfigSnapshot?.(event.config); - }) - : () => undefined; - - const unsubShell = input.client.orchestration.subscribeShell( - (item) => { - if (disposed) { - return; - } - - if (item.kind === "snapshot") { - input.syncShellSnapshot(item.snapshot, environmentId); - bootstrapGate.resolve(); - return; - } - - input.applyShellEvent(item, environmentId); - }, - { - onResubscribe: () => { - if (disposed) { - return; - } - - bootstrapGate.reset(); - input.onShellResubscribe?.(environmentId); - }, - }, - ); - - const unsubTerminalEvent = input.applyTerminalEvent - ? input.client.terminal.onEvent((event) => { - if (!disposed) { - input.applyTerminalEvent?.(event, environmentId); - } - }) - : () => undefined; - - const cleanup = () => { - if (disposed) { - return; - } - - disposed = true; - bootstrapGate.reject(new EnvironmentConnectionDisposedError(environmentId)); - unsubShell(); - unsubTerminalEvent(); - unsubLifecycle(); - unsubConfig(); - }; - - return { - kind: input.kind, - environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: () => - disposed - ? Promise.reject(new EnvironmentConnectionDisposedError(environmentId)) - : bootstrapGate.wait(), - reconnect: async () => { - if (disposed) { - throw new EnvironmentConnectionDisposedError(environmentId); - } - - bootstrapGate.reset(); - try { - await input.client.reconnect(); - await input.refreshMetadata?.(); - await bootstrapGate.wait(); - } catch (error) { - bootstrapGate.reject(error); - throw error; - } - }, - dispose: async () => { - cleanup(); - await input.client.dispose(); - }, - }; -} diff --git a/packages/client-runtime/src/environmentRuntimeState.test.ts b/packages/client-runtime/src/environmentRuntimeState.test.ts deleted file mode 100644 index 79b245335a9..00000000000 --- a/packages/client-runtime/src/environmentRuntimeState.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { createEnvironmentRuntimeManager } from "./environmentRuntimeState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; - -describe("createEnvironmentRuntimeManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("stores state per environment", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.setState(TARGET, { - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - }); - - it("patches the current state", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.patch(TARGET, (current) => ({ - ...current, - connectionState: "disconnected", - connectionError: "Socket closed.", - })); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "disconnected", - connectionError: "Socket closed.", - serverConfig: null, - }); - }); - - it("invalidates a single environment", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.setState(TARGET, { - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - manager.invalidate(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "idle", - connectionError: null, - serverConfig: null, - }); - }); -}); diff --git a/packages/client-runtime/src/environmentRuntimeState.ts b/packages/client-runtime/src/environmentRuntimeState.ts deleted file mode 100644 index e25979c8cfd..00000000000 --- a/packages/client-runtime/src/environmentRuntimeState.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { EnvironmentId, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type EnvironmentConnectionState = - | "idle" - | "connecting" - | "ready" - | "reconnecting" - | "disconnected"; - -export interface EnvironmentRuntimeState { - readonly connectionState: EnvironmentConnectionState; - readonly connectionError: string | null; - readonly serverConfig: T3ServerConfig | null; -} - -export interface EnvironmentRuntimeTarget { - readonly environmentId: EnvironmentId | null; -} - -export const EMPTY_ENVIRONMENT_RUNTIME_STATE = Object.freeze({ - connectionState: "idle", - connectionError: null, - serverConfig: null, -}); - -const knownEnvironmentRuntimeKeys = new Set(); - -export const environmentRuntimeStateAtom = Atom.family((key: string) => { - knownEnvironmentRuntimeKeys.add(key); - return Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`environment-runtime:${key}`), - ); -}); - -export const EMPTY_ENVIRONMENT_RUNTIME_ATOM = Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("environment-runtime:null"), -); - -export function getEnvironmentRuntimeTargetKey(target: EnvironmentRuntimeTarget): string | null { - return target.environmentId; -} - -export interface EnvironmentRuntimeManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; -} - -export function createEnvironmentRuntimeManager(config: EnvironmentRuntimeManagerConfig) { - function getSnapshot(target: EnvironmentRuntimeTarget): EnvironmentRuntimeState { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return EMPTY_ENVIRONMENT_RUNTIME_STATE; - } - - return config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); - } - - function setState(target: EnvironmentRuntimeTarget, nextState: EnvironmentRuntimeState): void { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return; - } - - config.getRegistry().set(environmentRuntimeStateAtom(targetKey), nextState); - } - - function patch( - target: EnvironmentRuntimeTarget, - updater: (current: EnvironmentRuntimeState) => EnvironmentRuntimeState, - ): void { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); - config.getRegistry().set(environmentRuntimeStateAtom(targetKey), updater(current)); - } - - function invalidate(target?: EnvironmentRuntimeTarget): void { - if (target) { - setState(target, EMPTY_ENVIRONMENT_RUNTIME_STATE); - return; - } - - for (const key of knownEnvironmentRuntimeKeys) { - config.getRegistry().set(environmentRuntimeStateAtom(key), EMPTY_ENVIRONMENT_RUNTIME_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - getSnapshot, - setState, - patch, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/errors/errorTrace.test.ts b/packages/client-runtime/src/errors/errorTrace.test.ts new file mode 100644 index 00000000000..25509899995 --- /dev/null +++ b/packages/client-runtime/src/errors/errorTrace.test.ts @@ -0,0 +1,44 @@ +import * as Cause from "effect/Cause"; +import { describe, expect, it } from "vite-plus/test"; + +import { findErrorTraceId } from "./errorTrace.ts"; + +describe("findErrorTraceId", () => { + it("finds trace metadata through wrapped typed errors", () => { + expect( + findErrorTraceId({ + cause: { + cause: { + _tag: "RelayInternalError", + traceId: "trace-relay", + }, + }, + }), + ).toBe("trace-relay"); + }); + + it("terminates for cyclic causes", () => { + const error: { cause?: unknown } = {}; + error.cause = error; + + expect(findErrorTraceId(error)).toBeNull(); + }); + + it("finds trace metadata in Effect cause branches", () => { + const cause = Cause.fromReasons([ + Cause.makeFailReason(new Error("first failure")), + Cause.makeFailReason({ traceId: "trace-secondary" }), + ]); + + expect(findErrorTraceId(cause)).toBe("trace-secondary"); + }); + + it("finds trace metadata in aggregate error branches", () => { + const error = new AggregateError( + [new Error("first failure"), { traceId: "trace-aggregate" }], + "request failed", + ); + + expect(findErrorTraceId(error)).toBe("trace-aggregate"); + }); +}); diff --git a/packages/client-runtime/src/errors/errorTrace.ts b/packages/client-runtime/src/errors/errorTrace.ts new file mode 100644 index 00000000000..74deb37c4f3 --- /dev/null +++ b/packages/client-runtime/src/errors/errorTrace.ts @@ -0,0 +1,50 @@ +import * as Cause from "effect/Cause"; + +const MAX_ERROR_TRACE_NODES = 128; + +export function findErrorTraceId(error: unknown): string | null { + const seen = new Set(); + const pending: Array = [error]; + let inspectedNodeCount = 0; + + while (pending.length > 0 && inspectedNodeCount < MAX_ERROR_TRACE_NODES) { + const current = pending.pop(); + inspectedNodeCount += 1; + if (typeof current !== "object" || current === null || seen.has(current)) { + continue; + } + seen.add(current); + const record = current as { + readonly cause?: unknown; + readonly errors?: unknown; + readonly traceId?: unknown; + }; + if (typeof record.traceId === "string" && record.traceId.trim().length > 0) { + return record.traceId; + } + + if (Array.isArray(record.errors)) { + for (let index = record.errors.length - 1; index >= 0; index -= 1) { + pending.push(record.errors[index]); + } + } + if (Cause.isCause(current)) { + for (let index = current.reasons.length - 1; index >= 0; index -= 1) { + const reason = current.reasons[index]; + switch (reason?._tag) { + case "Fail": + pending.push(reason.error); + break; + case "Die": + pending.push(reason.defect); + break; + } + } + } + if ("cause" in record) { + pending.push(record.cause); + } + } + + return null; +} diff --git a/packages/client-runtime/src/errors/index.ts b/packages/client-runtime/src/errors/index.ts new file mode 100644 index 00000000000..7eb6244e5a7 --- /dev/null +++ b/packages/client-runtime/src/errors/index.ts @@ -0,0 +1,3 @@ +export * from "./errorTrace.ts"; +export * from "./safeLog.ts"; +export * from "./transport.ts"; diff --git a/packages/client-runtime/src/errors/safeLog.test.ts b/packages/client-runtime/src/errors/safeLog.test.ts new file mode 100644 index 00000000000..b8dfb235ffe --- /dev/null +++ b/packages/client-runtime/src/errors/safeLog.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { safeErrorLogAttributes } from "./safeLog.ts"; + +describe("safeErrorLogAttributes", () => { + it("keeps correlation and stack frames without serializing messages or nested causes", () => { + const cause = Object.assign(new Error("nested-cause-secret-sentinel"), { + traceId: "trace-safe-123", + }); + const error = Object.assign(new Error("outer-error-secret-sentinel", { cause }), { + _tag: "ProjectRemovalError", + }); + error.stack = [ + "ProjectRemovalError: outer-error-secret-sentinel", + " at removeProject (https://user:password@example.com/project.ts?token=secret#fragment)", + ].join("\n"); + + const attributes = safeErrorLogAttributes(error); + + expect(attributes).toMatchObject({ + errorType: "error", + errorName: "Error", + errorTag: "ProjectRemovalError", + traceId: "trace-safe-123", + stack: " at removeProject (https://example.com/project.ts)", + }); + const diagnosticText = Object.values(attributes).map(String).join("\n"); + expect(diagnosticText).not.toContain("outer-error-secret-sentinel"); + expect(diagnosticText).not.toContain("nested-cause-secret-sentinel"); + expect(diagnosticText).not.toContain("user:password"); + expect(diagnosticText).not.toContain("token=secret"); + }); + + it("does not trust arbitrary object messages or tags", () => { + const attributes = safeErrorLogAttributes({ + _tag: "payload-secret-sentinel", + message: "message-secret-sentinel", + cause: { traceId: "trace id with unsafe whitespace" }, + }); + + expect(attributes).toEqual({ errorType: "object" }); + }); + + it("skips an unsafe outer trace id when a nested safe trace id is available", () => { + const attributes = safeErrorLogAttributes({ + traceId: "unsafe trace id", + cause: { traceId: "trace-safe-inner" }, + }); + + expect(attributes).toEqual({ + errorType: "object", + traceId: "trace-safe-inner", + }); + }); +}); diff --git a/packages/client-runtime/src/errors/safeLog.ts b/packages/client-runtime/src/errors/safeLog.ts new file mode 100644 index 00000000000..60730d4d35c --- /dev/null +++ b/packages/client-runtime/src/errors/safeLog.ts @@ -0,0 +1,107 @@ +const SAFE_ERROR_LABEL = + /^(?:Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError|AggregateError|DOMException|[A-Za-z][A-Za-z0-9]*(?:Error|Failure))$/; +const SAFE_TRACE_ID = /^[A-Za-z0-9._:-]{1,128}$/; +const STACK_FRAME_LIMIT = 32; + +export interface SafeErrorLogAttributes { + readonly errorType: "error" | "array" | "null" | "object" | "primitive"; + readonly errorName?: string; + readonly errorTag?: string; + readonly traceId?: string; + readonly stack?: string; +} + +function readSafeLabel(value: unknown): string | undefined { + return typeof value === "string" && SAFE_ERROR_LABEL.test(value) ? value : undefined; +} + +function sanitizeStackUrl(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return value; + } +} + +function sanitizeStackFrame(frame: string): string { + return frame.replace(/(?:https?|file):\/\/[^\s)]+/g, sanitizeStackUrl); +} + +function readSafeStack(error: Error): string | undefined { + try { + const frames = error.stack + ?.split(/\r?\n/) + .filter((line) => /^\s*at\s+/.test(line) || /^[^@\s]+@(?:https?|file):\/\//.test(line)) + .slice(0, STACK_FRAME_LIMIT) + .map(sanitizeStackFrame); + return frames && frames.length > 0 ? frames.join("\n") : undefined; + } catch { + return undefined; + } +} + +function readErrorTag(error: unknown): string | undefined { + try { + if (typeof error !== "object" || error === null) { + return undefined; + } + return readSafeLabel((error as { readonly _tag?: unknown })._tag); + } catch { + return undefined; + } +} + +function readTraceId(error: unknown): string | undefined { + try { + const seen = new Set(); + let current: unknown = error; + + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const record = current as { readonly cause?: unknown; readonly traceId?: unknown }; + if (typeof record.traceId === "string" && SAFE_TRACE_ID.test(record.traceId)) { + return record.traceId; + } + current = record.cause; + } + + return undefined; + } catch { + return undefined; + } +} + +export function safeErrorLogAttributes(error: unknown): SafeErrorLogAttributes { + const errorTag = readErrorTag(error); + const traceId = readTraceId(error); + + if (error instanceof Error) { + const errorName = readSafeLabel(error.name); + const stack = readSafeStack(error); + return { + errorType: "error", + ...(errorName !== undefined ? { errorName } : {}), + ...(errorTag !== undefined ? { errorTag } : {}), + ...(traceId !== undefined ? { traceId } : {}), + ...(stack !== undefined ? { stack } : {}), + }; + } + + return { + errorType: + error === null + ? "null" + : Array.isArray(error) + ? "array" + : typeof error === "object" + ? "object" + : "primitive", + ...(errorTag !== undefined ? { errorTag } : {}), + ...(traceId !== undefined ? { traceId } : {}), + }; +} diff --git a/packages/client-runtime/src/transportError.test.ts b/packages/client-runtime/src/errors/transport.test.ts similarity index 80% rename from packages/client-runtime/src/transportError.test.ts rename to packages/client-runtime/src/errors/transport.test.ts index 7c0417a91ef..692b3af4a51 100644 --- a/packages/client-runtime/src/transportError.test.ts +++ b/packages/client-runtime/src/errors/transport.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError.ts"; +import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transport.ts"; describe("isTransportConnectionErrorMessage", () => { it("returns true for SocketCloseError", () => { @@ -19,6 +19,17 @@ describe("isTransportConnectionErrorMessage", () => { ).toBe(true); }); + it("recognizes connection errors emitted by the Effect RPC session", () => { + expect(isTransportConnectionErrorMessage("Test environment disconnected.")).toBe(true); + expect( + isTransportConnectionErrorMessage( + "Test environment could not establish a WebSocket connection.", + ), + ).toBe(true); + expect(isTransportConnectionErrorMessage("Test environment is not connected.")).toBe(true); + expect(isTransportConnectionErrorMessage("ClientProtocolError: socket closed")).toBe(true); + }); + it("returns true for the T3 server WebSocket message", () => { expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( true, diff --git a/packages/client-runtime/src/transportError.ts b/packages/client-runtime/src/errors/transport.ts similarity index 81% rename from packages/client-runtime/src/transportError.ts rename to packages/client-runtime/src/errors/transport.ts index fe0ad9f98d6..e21c5d4ecf5 100644 --- a/packages/client-runtime/src/transportError.ts +++ b/packages/client-runtime/src/errors/transport.ts @@ -3,11 +3,16 @@ const TRANSPORT_ERROR_PATTERNS = [ /\bSocketOpenError\b/i, /\bSocket is not connected\b/i, /Unable to connect to the T3 server WebSocket\./i, + /\bis not connected\.$/i, + /\bdisconnected\.$/i, + /\bcould not establish a WebSocket connection\.$/i, + /\bClientProtocolError\b/i, + /\bRpcClientError\b/i, /\bping timeout\b/i, ] as const; /** - * Test whether an error message originates from a transport-level connection + * Check whether an error message originates from a transport-level connection * failure (socket close, socket open, ping timeout, etc.) rather than a * business-logic error. */ diff --git a/packages/client-runtime/src/filesystemBrowseState.test.ts b/packages/client-runtime/src/filesystemBrowseState.test.ts deleted file mode 100644 index c06ac6806ae..00000000000 --- a/packages/client-runtime/src/filesystemBrowseState.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { assert, beforeEach, it } from "vite-plus/test"; -import type { FilesystemBrowseResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - EMPTY_FILESYSTEM_BROWSE_STATE, - createFilesystemBrowseManager, -} from "./filesystemBrowseState.ts"; - -const ROOT_RESULT: FilesystemBrowseResult = { - parentPath: "/Users/julius", - entries: [ - { - name: "code", - fullPath: "/Users/julius/code", - }, - ], -}; - -let registry = AtomRegistry.make(); - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function unresolvedBrowse() { - throw new Error("Browse resolver was not initialized."); -} - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -it("stores browsed folder data in an atom snapshot", async () => { - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: async () => ROOT_RESULT, - }), - }); - - assert.deepStrictEqual( - manager.getSnapshot({ key: null, input: null }), - EMPTY_FILESYSTEM_BROWSE_STATE, - ); - - const target = { key: "env-1", input: { partialPath: "~" } }; - const result = await manager.refresh(target); - - assert.strictEqual(result, ROOT_RESULT); - assert.deepStrictEqual(manager.getSnapshot(target), { - data: ROOT_RESULT, - error: null, - isPending: false, - }); -}); - -it("deduplicates in-flight browse refreshes by target input", async () => { - let resolveBrowse: (result: FilesystemBrowseResult) => void = unresolvedBrowse; - let calls = 0; - const target = { key: "env-1", input: { partialPath: "~" } }; - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: () => { - calls += 1; - return new Promise((resolve) => { - resolveBrowse = resolve; - }); - }, - }), - }); - - const first = manager.refresh(target); - const second = manager.refresh(target); - - assert.strictEqual(first, second); - assert.strictEqual(calls, 1); - assert.deepStrictEqual(manager.getSnapshot(target), { - data: null, - error: null, - isPending: true, - }); - - resolveBrowse(ROOT_RESULT); - await first; - - assert.deepStrictEqual(manager.getSnapshot(target), { - data: ROOT_RESULT, - error: null, - isPending: false, - }); -}); - -it("keeps fresh watched browse results on remount", async () => { - let browseCalls = 0; - const target = { key: "env-1", input: { partialPath: "~" } }; - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: async () => { - browseCalls += 1; - return ROOT_RESULT; - }, - }), - staleTimeMs: 60_000, - }); - - const firstUnwatch = manager.watch(target); - await flushAsyncWork(); - firstUnwatch(); - - const secondUnwatch = manager.watch(target); - await flushAsyncWork(); - secondUnwatch(); - - assert.strictEqual(browseCalls, 1); -}); diff --git a/packages/client-runtime/src/filesystemBrowseState.ts b/packages/client-runtime/src/filesystemBrowseState.ts deleted file mode 100644 index b1c72966d4d..00000000000 --- a/packages/client-runtime/src/filesystemBrowseState.ts +++ /dev/null @@ -1,339 +0,0 @@ -import type { FilesystemBrowseInput, FilesystemBrowseResult } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface FilesystemBrowseState { - readonly data: FilesystemBrowseResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface FilesystemBrowseTarget { - readonly key: TKey | null; - readonly input: FilesystemBrowseInput | null; -} - -export interface FilesystemBrowseClient { - readonly browse: (input: FilesystemBrowseInput) => Promise; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -export const EMPTY_FILESYSTEM_BROWSE_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_FILESYSTEM_BROWSE_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownFilesystemBrowseKeys = new Set(); - -export const filesystemBrowseStateAtom = Atom.family((targetKey: string) => { - knownFilesystemBrowseKeys.add(targetKey); - return Atom.make(INITIAL_FILESYSTEM_BROWSE_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`filesystem-browse:${targetKey}`), - ); -}); - -export const EMPTY_FILESYSTEM_BROWSE_ATOM = Atom.make(EMPTY_FILESYSTEM_BROWSE_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("filesystem-browse:null"), -); - -const NOOP: () => void = () => undefined; -const DEFAULT_STALE_TIME_MS = 30_000; -const DEFAULT_IDLE_TTL_MS = 5 * 60_000; - -export function getFilesystemBrowseTargetKey( - target: FilesystemBrowseTarget, -): string | null { - const key = target.key; - const input = target.input; - if (!key || !input || input.partialPath.length === 0) { - return null; - } - - return JSON.stringify([key, input.cwd ?? null, input.partialPath]); -} - -export interface FilesystemBrowseManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (key: TKey) => FilesystemBrowseClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -} - -export function createFilesystemBrowseManager( - config: FilesystemBrowseManagerConfig, -) { - const refreshInFlight = new Map< - string, - { - readonly client: FilesystemBrowseClient; - readonly promise: Promise; - } - >(); - const refreshVersions = new Map(); - const watched = new Map(); - const refreshTargets = new Map>(); - const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; - const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? refresh(target) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: staleTimeMs, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtlMs), - Atom.withLabel(`filesystem-browse:watched-refresh:${targetKey}`), - ), - ); - - function getRefreshVersion(targetKey: string): number { - return refreshVersions.get(targetKey) ?? 0; - } - - function bumpRefreshVersion(targetKey: string): void { - refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); - } - - function setState(targetKey: string, nextState: FilesystemBrowseState): void { - config.getRegistry().set(filesystemBrowseStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - const next: FilesystemBrowseState = - current.data === null - ? INITIAL_FILESYSTEM_BROWSE_STATE - : { - data: current.data, - error: null, - isPending: true, - }; - - if ( - current.data === next.data && - current.error === next.error && - current.isPending === next.isPending - ) { - return; - } - - setState(targetKey, next); - } - - function setData(targetKey: string, data: FilesystemBrowseResult): void { - setState(targetKey, { - data, - error: null, - isPending: false, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: error instanceof Error ? error.message : "Failed to browse folder.", - isPending: false, - }); - } - - function refresh( - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): Promise { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null || target.key === null || target.input === null) { - return Promise.resolve(null); - } - refreshTargets.set(targetKey, target); - - const resolvedClient = client ?? config.getClient(target.key); - if (!resolvedClient) { - setError(targetKey, new Error("Filesystem browser client is unavailable.")); - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) { - if (!client || existing.client === resolvedClient) { - return existing.promise; - } - return existing.promise.then(() => refresh(target, resolvedClient)); - } - - markPending(targetKey); - const refreshVersion = getRefreshVersion(targetKey); - const promise = resolvedClient.browse(target.input).then( - (result) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setData(targetKey, result); - } - return result; - }, - (error: unknown) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setError(targetKey, error); - } - return getSnapshot(target).data; - }, - ); - - let tracked: Promise; - tracked = promise.finally(() => { - if (refreshInFlight.get(targetKey)?.promise === tracked) { - refreshInFlight.delete(targetKey); - } - }); - refreshInFlight.set(targetKey, { - client: resolvedClient, - promise: tracked, - }); - return tracked; - } - - function invalidate(target?: FilesystemBrowseTarget): void { - if (!target) { - reset(); - return; - } - - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null) { - return; - } - - bumpRefreshVersion(targetKey); - refreshInFlight.delete(targetKey); - setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); - } - - function getSnapshot(target: FilesystemBrowseTarget): FilesystemBrowseState { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null) { - return EMPTY_FILESYSTEM_BROWSE_STATE; - } - - return config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - } - - function watch( - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): () => void { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null || target.key === null) { - return NOOP; - } - refreshTargets.set(targetKey, target); - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - void refresh(target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: FilesystemBrowseClient | null = null; - - const sync = () => { - const resolved = config.getClient(target.key!); - if (!resolved) { - currentClient = null; - markPending(targetKey); - return; - } - - if (currentClient === resolved) { - return; - } - - const isClientReplacement = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); - }; - - const unsubChanges = config.subscribeClientChanges(sync); - sync(); - teardown = unsubChanges; - } else { - if (!config.getClient(target.key)) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function refreshWatchedTarget( - targetKey: string, - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): void { - refreshTargets.set(targetKey, target); - const registry = config.getRegistry(); - void registry.get(watchedRefreshAtom(targetKey)); - if (client) { - void refresh(target, client); - } - } - - function reset(): void { - refreshInFlight.clear(); - watched.clear(); - refreshTargets.clear(); - for (const targetKey of knownFilesystemBrowseKeys) { - bumpRefreshVersion(targetKey); - setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); - } - } - - return { - refresh, - invalidate, - getSnapshot, - watch, - reset, - }; -} diff --git a/packages/client-runtime/src/gitActions.test.ts b/packages/client-runtime/src/gitActions.test.ts deleted file mode 100644 index 39c6718347a..00000000000 --- a/packages/client-runtime/src/gitActions.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; -import { assert, describe, it } from "vite-plus/test"; - -import { resolveLiveThreadBranchUpdate } from "./gitActions.js"; - -function status(refName: string): VcsStatusResult { - return { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: refName === "main", - refName, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; -} - -describe("resolveLiveThreadBranchUpdate", () => { - it("allows a temporary worktree ref to reconcile to a semantic branch", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "t3code/a9628676", - gitStatus: status("feature/diff-panel-toggle"), - }); - - assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); - }); - - it("still reconciles ordinary semantic branch changes", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old", - gitStatus: status("feature/new"), - }); - - assert.deepEqual(update, { branch: "feature/new" }); - }); -}); diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts deleted file mode 100644 index ac32e794fe4..00000000000 --- a/packages/client-runtime/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export * from "./advertisedEndpoint.ts"; -export * from "./knownEnvironment.ts"; -export * from "./reconnectBackoff.ts"; -export * from "./scoped.ts"; -export * from "./projectPaths.ts"; -export * from "./addProject.ts"; -export * from "./filesystemBrowseState.ts"; -export * from "./sourceControlDiscoveryState.ts"; -export * from "./environmentRuntimeState.ts"; -export * from "./shellTypes.ts"; -export * from "./shellSnapshotReducer.ts"; -export * from "./shellSnapshotState.ts"; -export * from "./threadDetailReducer.ts"; -export * from "./threadDetailState.ts"; -export * from "./gitActions.ts"; -export * from "./vcsActionState.ts"; -export * from "./vcsRefState.ts"; -export * from "./vcsStatusState.ts"; -export * from "./terminalSessionState.ts"; -export * from "./transportError.ts"; -export * from "./wsRpcProtocol.ts"; -export * from "./wsTransport.ts"; -export * from "./wsRpcClient.ts"; -export * from "./environmentConnection.ts"; -export * from "./composerPathSearchState.ts"; -export * from "./archivedThreadsState.ts"; -export * from "./checkpointDiffState.ts"; -export * from "./remote.ts"; -export * from "./managedRelay.ts"; -export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/managedRelay.test.ts b/packages/client-runtime/src/managedRelay.test.ts deleted file mode 100644 index e340f12f620..00000000000 --- a/packages/client-runtime/src/managedRelay.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; -import { describe, expect, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; - -import { - MANAGED_RELAY_REQUEST_TIMEOUT_MS, - ManagedRelayClient, - ManagedRelayDpopSigner, - managedRelayClientLayer, - type ManagedRelayDpopProofInput, -} from "./managedRelay.ts"; -import { remoteHttpClientLayer } from "./remote.ts"; - -function managedRelayTestLayer( - fetchFn: typeof globalThis.fetch, - relayUrl = "https://relay.example.test", -) { - const httpClientLayer = remoteHttpClientLayer(fetchFn); - const signerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("client-thumbprint"), - createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), - }), - ); - return managedRelayClientLayer({ - relayUrl, - clientId: "t3-mobile", - }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); -} - -describe("ManagedRelayClient", () => { - it.effect("rejects unsafe relay URLs before sending credentials", () => { - let requestCount = 0; - const fetchFn = (() => { - requestCount += 1; - return Promise.resolve(Response.json({})); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const error = yield* relayClient - .listEnvironments({ clerkToken: "clerk-token" }) - .pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", - message: "Relay URL must be a secure absolute HTTPS origin.", - }); - expect(requestCount).toBe(0); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); - }); - - it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { - let tokenExchangeCount = 0; - const fetchFn = ((input) => { - const url = String(input); - if (url.endsWith("/v1/client/dpop-token")) { - tokenExchangeCount += 1; - return Promise.resolve( - Response.json({ - access_token: `relay-token-${tokenExchangeCount}`, - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 10, - scope: RelayEnvironmentStatusScope, - }), - ); - } - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - status: "online", - checkedAt: "2026-05-25T00:01:00.000Z", - descriptor: { - environmentId: "env-1", - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }), - ); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const statusInput = { - clerkToken: "clerk-token", - scopes: [RelayEnvironmentStatusScope], - environmentId: EnvironmentId.make("env-1"), - } as const; - - yield* relayClient.getEnvironmentStatus(statusInput); - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(1); - - yield* TestClock.adjust(Duration.seconds(6)); - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(2); - - yield* relayClient.resetTokenCache; - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(3); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); - }); - - it.effect("times out stalled relay environment listing requests", () => { - const fetchFn = (() => - new Promise(() => undefined)) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const errorFiber = yield* relayClient - .listEnvironments({ clerkToken: "clerk-token" }) - .pipe(Effect.flip, Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); - const error = yield* Fiber.join(errorFiber); - - expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", - message: "Relay environment listing timed out.", - }); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); - }); - - it.effect("lists account devices through the Clerk bearer client endpoint", () => { - const fetchFn = ((input, init) => { - expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); - expect(init?.headers).toMatchObject({ - authorization: "Bearer clerk-token", - }); - return Promise.resolve( - Response.json({ - devices: [ - { - deviceId: "device-1", - label: "Julius's iPhone", - platform: "ios", - iosMajorVersion: 18, - appVersion: "1.0.0", - notifications: { - enabled: false, - notifyOnApproval: true, - notifyOnInput: true, - notifyOnCompletion: true, - notifyOnFailure: true, - }, - liveActivities: { - enabled: true, - }, - updatedAt: "2026-06-01T00:00:00.000Z", - }, - ], - }), - ); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); - expect(devices).toMatchObject([ - { - deviceId: "device-1", - label: "Julius's iPhone", - notifications: { - enabled: false, - }, - }, - ]); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); - }); -}); diff --git a/packages/client-runtime/src/managedRelay.ts b/packages/client-runtime/src/managedRelay.ts deleted file mode 100644 index f4b9b1f9353..00000000000 --- a/packages/client-runtime/src/managedRelay.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { - RelayAccessTokenType, - RelayApi, - type RelayClientEnvironmentRecord, - type RelayClientDeviceRecord, - RelayConnectEnvironmentEndpoint, - type RelayDeviceRegistrationRequest, - type RelayDpopAccessTokenScope, - RelayDpopTokenExchangeGrantType, - type RelayEnvironmentConnectRequest, - type RelayEnvironmentConnectResponse, - type RelayEnvironmentLinkChallengeRequest, - type RelayEnvironmentLinkChallengeResponse, - type RelayEnvironmentLinkRequest, - type RelayEnvironmentLinkResponse, - type RelayEnvironmentStatusResponse, - RelayExchangeDpopAccessTokenEndpoint, - RelayGetEnvironmentStatusEndpoint, - RelayJwtSubjectTokenType, - type RelayLiveActivityRegistrationRequest, - RelayMobileRegistrationScope, - type RelayOkResponse, - type RelayPublicClientId, - RelayRegisterDeviceEndpoint, - RelayRegisterLiveActivityEndpoint, - RelayUnregisterDeviceEndpoint, -} from "@t3tools/contracts/relay"; -import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; -import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as SynchronizedRef from "effect/SynchronizedRef"; -import type { HttpMethod } from "effect/unstable/http/HttpMethod"; -import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; - -export interface ManagedRelayDpopProofInput { - readonly method: HttpMethod; - readonly url: string; - readonly accessToken?: string; -} - -export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ - readonly cause: unknown; -}> {} - -export interface ManagedRelayDpopSignerShape { - readonly thumbprint: Effect.Effect; - readonly createProof: ( - input: ManagedRelayDpopProofInput, - ) => Effect.Effect; -} - -export class ManagedRelayDpopSigner extends Context.Service< - ManagedRelayDpopSigner, - ManagedRelayDpopSignerShape ->()("@t3tools/client-runtime/managedRelay/ManagedRelayDpopSigner") {} - -export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; - -interface CachedRelayAccessToken { - readonly clerkToken: string; - readonly thumbprint: string; - readonly scopes: ReadonlyArray; - readonly accessToken: string; - readonly expiresAtMillis: number; -} - -export interface ManagedRelayAuthorization { - readonly accessToken: string; - readonly proof: string; - readonly thumbprint: string; -} - -export interface ManagedRelayClientLayerOptions { - readonly relayUrl: string; - readonly clientId: RelayPublicClientId; -} - -export interface ManagedRelayClientShape { - readonly relayUrl: string; - readonly listEnvironments: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly listDevices: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly createEnvironmentLinkChallenge: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkChallengeRequest; - }) => Effect.Effect; - readonly linkEnvironment: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkRequest; - }) => Effect.Effect; - readonly unlinkEnvironment: (input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly getEnvironmentStatus: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly connectEnvironment: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly deviceId?: string; - }) => Effect.Effect; - readonly registerDevice: (input: { - readonly clerkToken: string; - readonly payload: RelayDeviceRegistrationRequest; - }) => Effect.Effect; - readonly unregisterDevice: (input: { - readonly clerkToken: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly registerLiveActivity: (input: { - readonly clerkToken: string; - readonly payload: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect; - readonly resetTokenCache: Effect.Effect; -} - -export class ManagedRelayClient extends Context.Service< - ManagedRelayClient, - ManagedRelayClientShape ->()("@t3tools/client-runtime/managedRelay/ManagedRelayClient") {} - -function relayClientError(message: string, cause?: unknown): ManagedRelayClientError { - return new ManagedRelayClientError({ message, ...(cause === undefined ? {} : { cause }) }); -} - -function timeoutRelayRequest(message: string) { - return ( - request: Effect.Effect, - ): Effect.Effect => - request.pipe( - Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(relayClientError(message)), - onSome: Effect.succeed, - }), - ), - ); -} - -function tokenMatches( - token: CachedRelayAccessToken, - input: { - readonly clerkToken: string; - readonly thumbprint: string; - readonly scopes: ReadonlyArray; - readonly nowMillis: number; - }, -): boolean { - return ( - token.clerkToken === input.clerkToken && - token.thumbprint === input.thumbprint && - token.expiresAtMillis > input.nowMillis + 5_000 && - input.scopes.every((scope) => token.scopes.includes(scope)) - ); -} - -function bearerHeaders(clerkToken: string) { - return { authorization: `Bearer ${clerkToken}` }; -} - -function dpopHeaders(authorization: ManagedRelayAuthorization) { - return { - authorization: `DPoP ${authorization.accessToken}`, - dpop: authorization.proof, - }; -} - -function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { - const unavailable = () => - Effect.fail(relayClientError("Relay URL must be a secure absolute HTTPS origin.")); - return ManagedRelayClient.of({ - relayUrl, - listEnvironments: unavailable, - listDevices: unavailable, - createEnvironmentLinkChallenge: unavailable, - linkEnvironment: unavailable, - unlinkEnvironment: unavailable, - getEnvironmentStatus: unavailable, - connectEnvironment: unavailable, - registerDevice: unavailable, - unregisterDevice: unavailable, - registerLiveActivity: unavailable, - resetTokenCache: Effect.void, - }); -} - -export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { - return Layer.effect( - ManagedRelayClient, - Effect.gen(function* () { - const relayUrl = normalizeSecureRelayUrl(options.relayUrl); - if (relayUrl === null) { - return disabledManagedRelayClient(options.relayUrl); - } - const signer = yield* ManagedRelayDpopSigner; - const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); - const cachedTokens = yield* SynchronizedRef.make>([]); - const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); - - type DpopProofTarget = Pick; - const dpopProofTargets = { - exchangeAccessToken: (): DpopProofTarget => ({ - method: RelayExchangeDpopAccessTokenEndpoint.method, - url: urlBuilder.token.exchangeDpopAccessToken(), - }), - getEnvironmentStatus: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayGetEnvironmentStatusEndpoint.method, - url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), - }), - connectEnvironment: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayConnectEnvironmentEndpoint.method, - url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), - }), - registerDevice: (): DpopProofTarget => ({ - method: RelayRegisterDeviceEndpoint.method, - url: urlBuilder.mobile.registerDevice(), - }), - unregisterDevice: (deviceId: string): DpopProofTarget => ({ - method: RelayUnregisterDeviceEndpoint.method, - url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), - }), - registerLiveActivity: (): DpopProofTarget => ({ - method: RelayRegisterLiveActivityEndpoint.method, - url: urlBuilder.mobile.registerLiveActivity(), - }), - }; - - const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( - function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly thumbprint: string; - }) { - const nowMillis = yield* Clock.currentTimeMillis; - return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { - const activeTokens = tokens.filter( - (token) => token.expiresAtMillis > nowMillis + 5_000, - ); - const cached = activeTokens.find((token) => - tokenMatches(token, { ...input, nowMillis }), - ); - if (cached) { - return Effect.succeed([cached, activeTokens] as const); - } - return Effect.gen(function* () { - const proof = yield* signer - .createProof(dpopProofTargets.exchangeAccessToken()) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay token DPoP proof.", cause), - ), - ); - const response = yield* client.token - .exchangeDpopAccessToken({ - headers: { dpop: proof }, - payload: { - grant_type: RelayDpopTokenExchangeGrantType, - subject_token: input.clerkToken, - subject_token_type: RelayJwtSubjectTokenType, - requested_token_type: RelayAccessTokenType, - resource: relayUrl, - scope: encodeOAuthScope(input.scopes), - client_id: options.clientId, - }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not exchange relay DPoP access token.", cause), - ), - timeoutRelayRequest("Relay DPoP access token exchange timed out."), - ); - if (!oauthScopeSetEquals(response.scope, input.scopes)) { - return yield* relayClientError( - "Relay granted unexpected DPoP access token scopes.", - ); - } - const next: CachedRelayAccessToken = { - clerkToken: input.clerkToken, - thumbprint: input.thumbprint, - scopes: input.scopes, - accessToken: response.access_token, - expiresAtMillis: nowMillis + response.expires_in * 1_000, - }; - return [next, [...activeTokens, next]] as const; - }); - }); - }, - ); - - const authorize = (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly target: DpopProofTarget; - }) => - Effect.gen(function* () { - const thumbprint = yield* signer.thumbprint.pipe( - Effect.mapError((cause) => - relayClientError("Could not load relay DPoP proof key.", cause), - ), - ); - const token = yield* obtainAccessToken({ - clerkToken: input.clerkToken, - scopes: input.scopes, - thumbprint, - }); - const proof = yield* signer - .createProof({ - ...input.target, - accessToken: token.accessToken, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay request DPoP proof.", cause), - ), - ); - return { accessToken: token.accessToken, proof, thumbprint }; - }); - - const authorizeMobileRegistration = (input: { - readonly clerkToken: string; - readonly target: DpopProofTarget; - }) => - authorize({ - ...input, - scopes: [RelayMobileRegistrationScope], - }); - - return ManagedRelayClient.of({ - relayUrl, - listEnvironments: (input) => - client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( - Effect.map((response) => response.environments), - Effect.mapError((cause) => - relayClientError("Could not list relay-managed environments.", cause), - ), - timeoutRelayRequest("Relay environment listing timed out."), - withRelayClientTracing, - ), - listDevices: (input) => - client.client - .listDevices({ - headers: bearerHeaders(input.clerkToken), - }) - .pipe( - Effect.map((response) => response.devices), - Effect.mapError((cause) => - relayClientError("Could not list relay client devices.", cause), - ), - timeoutRelayRequest("Relay client device listing timed out."), - withRelayClientTracing, - ), - createEnvironmentLinkChallenge: (input) => - client.client - .createEnvironmentLinkChallenge({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay environment link challenge.", cause), - ), - timeoutRelayRequest("Relay environment link challenge timed out."), - withRelayClientTracing, - ), - linkEnvironment: (input) => - client.client - .linkEnvironment({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not link relay environment.", cause), - ), - timeoutRelayRequest("Relay environment linking timed out."), - withRelayClientTracing, - ), - unlinkEnvironment: (input) => - client.client - .unlinkEnvironment({ - headers: bearerHeaders(input.clerkToken), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not unlink relay environment.", cause), - ), - timeoutRelayRequest("Relay environment unlinking timed out."), - withRelayClientTracing, - ), - getEnvironmentStatus: (input) => - Effect.gen(function* () { - const authorization = yield* authorize({ - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.getEnvironmentStatus(input.environmentId), - }); - return yield* client.dpopClient - .getEnvironmentStatus({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not get relay environment status.", cause), - ), - timeoutRelayRequest("Relay environment status request timed out."), - ); - }).pipe(withRelayClientTracing), - connectEnvironment: (input) => - Effect.gen(function* () { - const authorization = yield* authorize({ - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.connectEnvironment(input.environmentId), - }); - const payload: RelayEnvironmentConnectRequest = { - ...(input.deviceId ? { deviceId: input.deviceId } : {}), - clientKeyThumbprint: authorization.thumbprint, - }; - return yield* client.dpopClient - .connectEnvironment({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not connect relay environment.", cause), - ), - timeoutRelayRequest("Relay environment connection timed out."), - ); - }).pipe(withRelayClientTracing), - registerDevice: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.registerDevice(), - }); - return yield* client.mobile - .registerDevice({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not register relay mobile device.", cause), - ), - timeoutRelayRequest("Relay mobile device registration timed out."), - ); - }).pipe(withRelayClientTracing), - unregisterDevice: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.unregisterDevice(input.deviceId), - }); - return yield* client.mobile - .unregisterDevice({ - headers: dpopHeaders(authorization), - params: { deviceId: input.deviceId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not unregister relay mobile device.", cause), - ), - timeoutRelayRequest("Relay mobile device unregistration timed out."), - ); - }).pipe(withRelayClientTracing), - registerLiveActivity: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.registerLiveActivity(), - }); - return yield* client.mobile - .registerLiveActivity({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not register relay live activity.", cause), - ), - timeoutRelayRequest("Relay Live Activity registration timed out."), - ); - }).pipe(withRelayClientTracing), - resetTokenCache: SynchronizedRef.set(cachedTokens, []), - }); - }), - ); -} diff --git a/packages/client-runtime/src/managedRelayState.test.ts b/packages/client-runtime/src/managedRelayState.test.ts deleted file mode 100644 index ce58241e796..00000000000 --- a/packages/client-runtime/src/managedRelayState.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import type { - RelayClientDeviceRecord, - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, -} from "@t3tools/contracts/relay"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay.ts"; -import { - createManagedRelayQueryManager, - createManagedRelaySession, - managedRelaySessionAtom, - readManagedRelaySnapshotState, - setManagedRelaySession, - waitForManagedRelayClerkToken, -} from "./managedRelayState.ts"; - -let registry = AtomRegistry.make(); - -const environment = { - environmentId: EnvironmentId.make("environment-1"), - label: "Main environment", - endpoint: { - httpBaseUrl: "https://environment.example.test", - wsBaseUrl: "wss://environment.example.test", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-06-01T00:00:00.000Z", -} satisfies RelayClientEnvironmentRecord; - -const device = { - deviceId: "device-1", - label: "Julius iPhone", - platform: "ios", - iosMajorVersion: 18, - appVersion: null, - notifications: { - enabled: true, - notifyOnApproval: true, - notifyOnInput: true, - notifyOnCompletion: true, - notifyOnFailure: true, - }, - liveActivities: { - enabled: true, - }, - updatedAt: "2026-06-01T00:00:00.000Z", -} satisfies RelayClientDeviceRecord; - -function resetRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -function createManager(overrides?: Partial) { - const client = ManagedRelayClient.of({ - relayUrl: "https://relay.example.test", - listEnvironments: () => Effect.succeed([environment]), - listDevices: () => Effect.succeed([device]), - createEnvironmentLinkChallenge: () => Effect.die("unused"), - linkEnvironment: () => Effect.die("unused"), - unlinkEnvironment: () => Effect.die("unused"), - getEnvironmentStatus: () => - Effect.succeed({ - environmentId: environment.environmentId, - endpoint: environment.endpoint, - status: "online", - checkedAt: "2026-06-01T00:00:00.000Z", - }), - connectEnvironment: () => Effect.die("unused"), - registerDevice: () => Effect.die("unused"), - unregisterDevice: () => Effect.die("unused"), - registerLiveActivity: () => Effect.die("unused"), - resetTokenCache: Effect.void, - ...overrides, - }); - const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); - return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000 }); -} - -function setSession() { - setManagedRelaySession( - registry, - createManagedRelaySession({ - accountId: "account-1", - readClerkToken: () => Promise.resolve("clerk-token"), - }), - ); -} - -describe("createManagedRelayQueryManager", () => { - afterEach(resetRegistry); - - it("waits for the current cloud session before reading its token", async () => { - const token = Effect.runPromise(waitForManagedRelayClerkToken(registry)); - - setSession(); - - await expect(token).resolves.toBe("clerk-token"); - expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); - }); - - it("keeps environment snapshots cached and refreshes them explicitly", async () => { - const listEnvironments = vi.fn(() => Effect.succeed([environment])); - const manager = createManager({ listEnvironments }); - setSession(); - const atom = manager.environmentsAtom("account-1"); - - registry.get(atom); - await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); - - registry.get(manager.environmentsAtom("account-1")); - expect(listEnvironments).toHaveBeenCalledTimes(1); - - manager.refreshEnvironments(registry, "account-1"); - await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); - }); - - it("loads device snapshots through the current account session", async () => { - const listDevices = vi.fn(() => Effect.succeed([device])); - const manager = createManager({ listDevices }); - setSession(); - const atom = manager.devicesAtom("account-1"); - - registry.get(atom); - await vi.waitFor(() => { - expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); - }); - }); - - it("rejects status responses for a different environment", async () => { - const mismatchedStatus = { - environmentId: EnvironmentId.make("environment-2"), - endpoint: environment.endpoint, - status: "online", - checkedAt: "2026-06-01T00:00:00.000Z", - } satisfies RelayEnvironmentStatusResponse; - const manager = createManager({ - getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), - }); - setSession(); - const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); - - registry.get(atom); - await vi.waitFor(() => { - expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( - "Relay returned status for a different environment.", - ); - }); - }); -}); diff --git a/packages/client-runtime/src/managedRelayState.ts b/packages/client-runtime/src/managedRelayState.ts deleted file mode 100644 index f9cfab82594..00000000000 --- a/packages/client-runtime/src/managedRelayState.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, -} from "@t3tools/contracts/relay"; -import { - RelayEnvironmentConnectScope, - RelayEnvironmentStatusScope, -} from "@t3tools/contracts/relay"; -import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { ManagedRelayClient } from "./managedRelay.ts"; - -const DEFAULT_STALE_TIME_MS = 15_000; -const DEFAULT_IDLE_TTL_MS = 5 * 60_000; - -export interface ManagedRelaySession { - readonly accountId: string; - readonly readClerkToken: () => Effect.Effect; -} - -export interface ManagedRelaySnapshotState { - readonly data: A | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{ - readonly message: string; -}> {} - -export const managedRelaySessionAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("managed-relay:session"), -); - -export function createManagedRelaySession(input: { - readonly accountId: string; - readonly readClerkToken: () => Promise; -}): ManagedRelaySession { - return { - accountId: input.accountId, - readClerkToken: () => - Effect.tryPromise({ - try: input.readClerkToken, - catch: (cause) => - new ManagedRelaySessionError({ - message: "Could not obtain the T3 Connect session token.", - cause, - }), - }), - }; -} - -export function setManagedRelaySession( - registry: AtomRegistry.AtomRegistry, - session: ManagedRelaySession | null, -): void { - registry.set(managedRelaySessionAtom, session); -} - -function readSessionClerkToken( - session: ManagedRelaySession, -): Effect.Effect { - return session.readClerkToken().pipe( - Effect.flatMap((token) => - token - ? Effect.succeed(token) - : Effect.fail( - new ManagedRelaySessionError({ - message: "The T3 Connect session token is unavailable.", - }), - ), - ), - ); -} - -export function waitForManagedRelayClerkToken( - registry: AtomRegistry.AtomRegistry, -): Effect.Effect { - return Effect.callback((resume) => { - let unsubscribe: (() => void) | undefined; - let completed = false; - const readCurrentSession = () => { - if (completed) { - return true; - } - const session = registry.get(managedRelaySessionAtom); - if (!session) { - return false; - } - completed = true; - unsubscribe?.(); - resume(readSessionClerkToken(session)); - return true; - }; - - if (readCurrentSession()) { - return; - } - - unsubscribe = registry.subscribe(managedRelaySessionAtom, readCurrentSession); - readCurrentSession(); - return Effect.sync(() => unsubscribe?.()); - }); -} - -function requireClerkToken( - get: Atom.AtomContext, - accountId: string, -): Effect.Effect { - const session = get(managedRelaySessionAtom); - if (!session || session.accountId !== accountId) { - return Effect.fail( - new ManagedRelaySessionError({ - message: "Sign in to T3 Connect before loading relay data.", - }), - ); - } - return readSessionClerkToken(session); -} - -function statusKey(input: { - readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; -}): string { - return JSON.stringify(input); -} - -function parseStatusKey(key: string): { - readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; -} { - return JSON.parse(key) as { - readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; - }; -} - -function endpointMatches( - left: RelayClientEnvironmentRecord["endpoint"], - right: RelayClientEnvironmentRecord["endpoint"], -): boolean { - return ( - left.httpBaseUrl === right.httpBaseUrl && - left.wsBaseUrl === right.wsBaseUrl && - left.providerKind === right.providerKind - ); -} - -function validateEnvironmentStatus( - environment: RelayClientEnvironmentRecord, - status: RelayEnvironmentStatusResponse, -): Effect.Effect { - if (status.environmentId !== environment.environmentId) { - return Effect.fail( - new ManagedRelaySnapshotError({ - message: "Relay returned status for a different environment.", - }), - ); - } - if (!endpointMatches(status.endpoint, environment.endpoint)) { - return Effect.fail( - new ManagedRelaySnapshotError({ - message: "Relay returned status for a different endpoint.", - }), - ); - } - if (status.descriptor && status.descriptor.environmentId !== environment.environmentId) { - return Effect.fail( - new ManagedRelaySnapshotError({ - message: "Relay returned status descriptor for a different environment.", - }), - ); - } - return Effect.succeed(status); -} - -export function readManagedRelaySnapshotState( - result: AsyncResult.AsyncResult, -): ManagedRelaySnapshotState { - let error: string | null = null; - if (result._tag === "Failure") { - const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; - } - return { - data: Option.getOrNull(AsyncResult.value(result)), - error, - isPending: result.waiting, - }; -} - -export function createManagedRelayQueryManager( - runtime: Atom.AtomRuntime, - options?: { - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; - }, -) { - const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; - const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; - - const environmentsAtom = Atom.family((accountId: string) => - runtime - .atom((get) => - Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); - const relay = yield* ManagedRelayClient; - return yield* relay.listEnvironments({ clerkToken }); - }), - ) - .pipe( - Atom.swr({ staleTime, revalidateOnMount: true }), - Atom.setIdleTTL(idleTtl), - Atom.withLabel(`managed-relay:environments:${accountId}`), - ), - ); - - const devicesAtom = Atom.family((accountId: string) => - runtime - .atom((get) => - Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); - const relay = yield* ManagedRelayClient; - return yield* relay.listDevices({ clerkToken }); - }), - ) - .pipe( - Atom.swr({ staleTime, revalidateOnMount: true }), - Atom.setIdleTTL(idleTtl), - Atom.withLabel(`managed-relay:devices:${accountId}`), - ), - ); - - const environmentStatusAtom = Atom.family((key: string) => { - const { accountId, environment } = parseStatusKey(key); - return runtime - .atom((get) => - Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); - const relay = yield* ManagedRelayClient; - const status = yield* relay.getEnvironmentStatus({ - clerkToken, - scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], - environmentId: environment.environmentId, - }); - return yield* validateEnvironmentStatus(environment, status); - }), - ) - .pipe( - Atom.swr({ staleTime, revalidateOnMount: true }), - Atom.setIdleTTL(idleTtl), - Atom.withLabel(`managed-relay:environment-status:${key}`), - ); - }); - - return { - environmentsAtom, - devicesAtom, - environmentStatusAtom: (input: { - readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; - }) => environmentStatusAtom(statusKey(input)), - refreshEnvironments(registry: AtomRegistry.AtomRegistry, accountId: string): void { - registry.refresh(environmentsAtom(accountId)); - }, - refreshDevices(registry: AtomRegistry.AtomRegistry, accountId: string): void { - registry.refresh(devicesAtom(accountId)); - }, - refreshEnvironmentStatus( - registry: AtomRegistry.AtomRegistry, - input: { - readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; - }, - ): void { - registry.refresh(environmentStatusAtom(statusKey(input))); - }, - }; -} diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts new file mode 100644 index 00000000000..5cc3f0c1a86 --- /dev/null +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -0,0 +1,137 @@ +import { + CommandId, + EnvironmentId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ThreadId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcSession from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; + +const TEST_CRYPTO_LAYER = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, data) => Effect.succeed(data), + }), +); + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* ( + dispatched: ClientOrchestrationCommand[], +) { + const client = { + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: ClientOrchestrationCommand) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + } as unknown as WsRpcProtocolClient; + const session: RpcSession.RpcSession = { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); +}); + +describe("environment commands", () => { + it.effect("adds generated command metadata", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + const result = yield* createProject({ + projectId: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/workspace/project", + createdAt: "2026-06-06T00:00:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(result).toEqual({ sequence: 1 }); + expect(dispatched).toEqual([ + { + type: "project.create", + commandId: "00000000-0000-4000-8000-000000000000", + projectId: "project-1", + title: "Project", + workspaceRoot: "/workspace/project", + createdAt: "2026-06-06T00:00:00.000Z", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("preserves caller metadata for idempotent queued commands", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* stopThreadSession({ + commandId: CommandId.make("queued-command"), + threadId: ThreadId.make("thread-1"), + createdAt: "2026-06-06T00:01:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.session.stop", + commandId: "queued-command", + threadId: "thread-1", + createdAt: "2026-06-06T00:01:00.000Z", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("does not add timestamps to commands without createdAt", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* archiveThread({ + commandId: CommandId.make("archive-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.archive", + commandId: "archive-command", + threadId: "thread-1", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); +}); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts new file mode 100644 index 00000000000..f6e49577c0f --- /dev/null +++ b/packages/client-runtime/src/operations/commands.ts @@ -0,0 +1,269 @@ +import { + CommandId, + ORCHESTRATION_WS_METHODS, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import type { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { + type EnvironmentRpcFailure, + type EnvironmentRpcSuccess, + type EnvironmentRpcUnavailableError, + request, +} from "../rpc/client.ts"; + +type CommandType = ClientOrchestrationCommand["type"]; +type CommandOf = Extract; +type CommandInput = Omit< + CommandOf, + "type" | "commandId" | "createdAt" +> & { + readonly commandId?: CommandId; +} & ("createdAt" extends keyof CommandOf + ? { + readonly createdAt?: CommandOf["createdAt"]; + } + : {}); + +export type CreateProjectInput = CommandInput<"project.create">; +export type UpdateProjectInput = CommandInput<"project.meta.update">; +export type DeleteProjectInput = CommandInput<"project.delete">; +export type CreateThreadInput = CommandInput<"thread.create">; +export type DeleteThreadInput = CommandInput<"thread.delete">; +export type ArchiveThreadInput = CommandInput<"thread.archive">; +export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; +export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; +export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; +export type StartThreadTurnInput = CommandInput<"thread.turn.start">; +export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; +export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; +export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; +export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; +export type StopThreadSessionInput = CommandInput<"thread.session.stop">; +export type RequestThreadGoalInput = CommandInput<"thread.goal.request">; + +type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; +type CommandEffect = Effect.Effect< + EnvironmentRpcSuccess, + EnvironmentRpcFailure | EnvironmentRpcUnavailableError, + Crypto.Crypto | EnvironmentSupervisor +>; + +function commandId(input: { readonly commandId?: CommandId }) { + return Effect.gen(function* () { + if (input.commandId !== undefined) { + return input.commandId; + } + const crypto = yield* Crypto.Crypto; + return yield* crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(CommandId.make)); + }); +} + +function timestampedCommandMetadata(input: { + readonly commandId?: CommandId; + readonly createdAt?: string; +}) { + return Effect.all({ + commandId: commandId(input), + createdAt: + input.createdAt === undefined + ? DateTime.now.pipe(Effect.map(DateTime.formatIso)) + : Effect.succeed(input.createdAt), + }); +} + +function dispatch(command: ClientOrchestrationCommand) { + return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command); +} + +export const createProject: (input: CreateProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.createProject", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "project.create", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.updateProject", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "project.meta.update", + commandId: yield* commandId(input), + }); +}); + +export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.deleteProject", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "project.delete", + commandId: yield* commandId(input), + }); +}); + +export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.createThread", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.create", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const deleteThread: (input: DeleteThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.deleteThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.delete", + commandId: yield* commandId(input), + }); +}); + +export const archiveThread: (input: ArchiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.archiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.archive", + commandId: yield* commandId(input), + }); +}); + +export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unarchiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unarchive", + commandId: yield* commandId(input), + }); +}); + +export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.updateThreadMetadata", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.meta.update", + commandId: yield* commandId(input), + }); +}); + +export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.setThreadRuntimeMode", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.runtime-mode.set", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const setThreadInteractionMode: (input: SetThreadInteractionModeInput) => CommandEffect = + Effect.fn("EnvironmentCommands.setThreadInteractionMode")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.interaction-mode.set", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.startThreadTurn", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.turn.start", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.interruptThreadTurn", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.turn.interrupt", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect = + Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.approval.respond", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => CommandEffect = + Effect.fn("EnvironmentCommands.respondToThreadUserInput")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.user-input.respond", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect = + Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.checkpoint.revert", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.stopThreadSession", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.session.stop", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const requestThreadGoal: (input: RequestThreadGoalInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.requestThreadGoal", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.goal.request", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); diff --git a/packages/client-runtime/src/operations/index.ts b/packages/client-runtime/src/operations/index.ts new file mode 100644 index 00000000000..b7307fbb81f --- /dev/null +++ b/packages/client-runtime/src/operations/index.ts @@ -0,0 +1,2 @@ +export * from "./commands.ts"; +export * from "./projects.ts"; diff --git a/packages/client-runtime/src/addProject.test.ts b/packages/client-runtime/src/operations/projects.test.ts similarity index 96% rename from packages/client-runtime/src/addProject.test.ts rename to packages/client-runtime/src/operations/projects.test.ts index fb665996a98..bf4e2c89392 100644 --- a/packages/client-runtime/src/addProject.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -14,8 +14,8 @@ import { getAddProjectInitialQuery, resolveAddProjectPath, sortAddProjectProviderSources, -} from "./addProject.ts"; -import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; +} from "./projects.ts"; +import type { EnvironmentProject } from "../state/models.ts"; describe("add project shared logic", () => { it("resolves initial browse paths from settings", () => { @@ -92,7 +92,7 @@ describe("add project shared logic", () => { it("finds existing projects by normalized path in the target environment", () => { const env = EnvironmentId.make("env"); const other = EnvironmentId.make("other"); - const projects: EnvironmentScopedProjectShell[] = [ + const projects: EnvironmentProject[] = [ { environmentId: other, id: ProjectId.make("same-path-other-env"), diff --git a/packages/client-runtime/src/addProject.ts b/packages/client-runtime/src/operations/projects.ts similarity index 94% rename from packages/client-runtime/src/addProject.ts rename to packages/client-runtime/src/operations/projects.ts index fb4e599317f..ec58418a94f 100644 --- a/packages/client-runtime/src/addProject.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -19,8 +19,8 @@ import { isExplicitRelativeProjectPath, isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, -} from "./projectPaths.ts"; -import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; +} from "../state/projects.ts"; +import type { EnvironmentProject } from "../state/models.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, @@ -48,7 +48,7 @@ export type AddProjectCloneFlow = readonly remoteUrl: string; }; -export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ +const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ "url", "github", "gitlab", @@ -56,7 +56,7 @@ export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = "azure-devops", ]; -export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ +const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ "github", "gitlab", "bitbucket", @@ -190,10 +190,10 @@ export function resolveAddProjectPath(input: { } export function findExistingAddProject(input: { - readonly projects: ReadonlyArray; + readonly projects: ReadonlyArray; readonly environmentId: EnvironmentId; readonly path: string; -}): EnvironmentScopedProjectShell | null { +}): EnvironmentProject | null { return ( findProjectByPath( input.projects.filter((project) => project.environmentId === input.environmentId), diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts new file mode 100644 index 00000000000..a20b7d404b2 --- /dev/null +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -0,0 +1,68 @@ +import { + type AuthClientPresentationMetadata, + type AuthEnvironmentScope, + type DesktopSshEnvironmentBootstrap, + type DesktopSshEnvironmentTarget, + EnvironmentId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; + +import type { ConnectionAttemptError } from "../connection/model.ts"; + +export interface PreparedSshEnvironment { + readonly bootstrap: DesktopSshEnvironmentBootstrap; + readonly bearerToken: string; +} + +export interface ProvisionedSshEnvironment extends PreparedSshEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export class CloudSession extends Context.Service< + CloudSession, + { + readonly clerkToken: Effect.Effect; + } +>()("@t3tools/client-runtime/platform/capabilities/CloudSession") {} + +export class RelayDeviceIdentity extends Context.Service< + RelayDeviceIdentity, + { + readonly deviceId: Effect.Effect, ConnectionAttemptError>; + } +>()("@t3tools/client-runtime/platform/capabilities/RelayDeviceIdentity") {} + +export class ClientPresentation extends Context.Service< + ClientPresentation, + { + readonly metadata: AuthClientPresentationMetadata; + readonly scopes: ReadonlyArray; + } +>()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} + +export class PrimaryEnvironmentAuth extends Context.Service< + PrimaryEnvironmentAuth, + { + readonly bearerToken: Effect.Effect, ConnectionAttemptError>; + } +>()("@t3tools/client-runtime/platform/capabilities/PrimaryEnvironmentAuth") {} + +export class SshEnvironmentGateway extends Context.Service< + SshEnvironmentGateway, + { + readonly provision: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + readonly prepare: (input: { + readonly connectionId: string; + readonly expectedEnvironmentId: EnvironmentId; + readonly target: DesktopSshEnvironmentTarget; + }) => Effect.Effect; + readonly disconnect: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {} diff --git a/packages/client-runtime/src/platform/index.ts b/packages/client-runtime/src/platform/index.ts new file mode 100644 index 00000000000..0c937549771 --- /dev/null +++ b/packages/client-runtime/src/platform/index.ts @@ -0,0 +1,4 @@ +export * from "./capabilities.ts"; +export * from "./persistence.ts"; +export * from "./source.ts"; +export * from "./storageDocument.ts"; diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts new file mode 100644 index 00000000000..71664bf4601 --- /dev/null +++ b/packages/client-runtime/src/platform/persistence.ts @@ -0,0 +1,84 @@ +import { + type EnvironmentId, + type OrchestrationThread, + type OrchestrationShellSnapshot, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ConnectionRegistration } from "../connection/catalog.ts"; +import type { ConnectionTarget } from "../connection/model.ts"; + +export class ConnectionPersistenceError extends Schema.TaggedErrorClass()( + "ConnectionPersistenceError", + { + operation: Schema.Literals([ + "list-targets", + "register-connection", + "remove-connection", + "load-shell", + "save-shell", + "load-thread", + "save-thread", + "remove-thread", + "clear-environment", + ]), + message: Schema.String, + }, +) {} + +export class ConnectionTargetStore extends Context.Service< + ConnectionTargetStore, + { + readonly list: Effect.Effect, ConnectionPersistenceError>; + } +>()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} + +export class ConnectionRegistrationStore extends Context.Service< + ConnectionRegistrationStore, + { + readonly register: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly remove: (target: ConnectionTarget) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} + +export class EnvironmentCacheStore extends Context.Service< + EnvironmentCacheStore, + { + readonly loadShell: ( + environmentId: EnvironmentId, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly saveShell: ( + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, + ) => Effect.Effect; + readonly loadThread: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly saveThread: ( + environmentId: EnvironmentId, + thread: OrchestrationThread, + ) => Effect.Effect; + readonly removeThread: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect; + readonly clear: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} + +export class EnvironmentOwnedDataCleanup extends Context.Reference<{ + readonly clear: (environmentId: EnvironmentId) => Effect.Effect; +}>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { + defaultValue: () => ({ + clear: () => Effect.void, + }), +}) {} diff --git a/packages/client-runtime/src/platform/source.ts b/packages/client-runtime/src/platform/source.ts new file mode 100644 index 00000000000..8b5bbeeea5f --- /dev/null +++ b/packages/client-runtime/src/platform/source.ts @@ -0,0 +1,11 @@ +import * as Context from "effect/Context"; +import type * as Stream from "effect/Stream"; + +import type { PrimaryConnectionRegistration } from "../connection/catalog.ts"; + +export class PlatformConnectionSource extends Context.Service< + PlatformConnectionSource, + { + readonly registrations: Stream.Stream; + } +>()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {} diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts new file mode 100644 index 00000000000..8ad6b81e12b --- /dev/null +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -0,0 +1,146 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import * as TokenStore from "../authorization/tokenStore.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + RelayConnectionRegistration, + SshConnectionProfile, + SshConnectionRegistration, +} from "../connection/catalog.ts"; +import { + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "../connection/model.ts"; +import { + EMPTY_CONNECTION_CATALOG_DOCUMENT, + registerConnectionInCatalog, + removeConnectionFromCatalog, +} from "./storageDocument.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +const BEARER_TARGET = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + connectionId: "bearer-1", +}); +const BEARER_PROFILE = new BearerConnectionProfile({ + connectionId: BEARER_TARGET.connectionId, + environmentId: ENVIRONMENT_ID, + label: BEARER_TARGET.label, + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test", +}); +const BEARER_CREDENTIAL = new BearerConnectionCredential({ + token: "bearer-token", +}); +const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + endpoint: { + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test", + providerKind: "cloudflare_tunnel", + }, + accessToken: "dpop-token", + expiresAtEpochMs: 1_000_000, + dpopThumbprint: "thumbprint", +}); + +describe("ConnectionCatalogDocument", () => { + it("registers a bearer connection as one catalog mutation", () => { + const document = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + + expect(document.targets).toEqual([BEARER_TARGET]); + expect(document.profiles).toEqual([BEARER_PROFILE]); + expect(document.credentials).toEqual([ + { + connectionId: BEARER_TARGET.connectionId, + credential: BEARER_CREDENTIAL, + }, + ]); + }); + + it("replaces obsolete connection metadata without discarding a reusable DPoP token", () => { + const bearer = registerConnectionInCatalog( + { + ...EMPTY_CONNECTION_CATALOG_DOCUMENT, + remoteDpopTokens: [REMOTE_TOKEN], + }, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + const relayTarget = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + }); + const relay = registerConnectionInCatalog( + bearer, + new RelayConnectionRegistration({ target: relayTarget }), + ); + + expect(relay.targets).toEqual([relayTarget]); + expect(relay.profiles).toEqual([]); + expect(relay.credentials).toEqual([]); + expect(relay.remoteDpopTokens).toEqual([REMOTE_TOKEN]); + }); + + it("removes every catalog record owned by an explicit disconnect", () => { + const registered = registerConnectionInCatalog( + { + ...EMPTY_CONNECTION_CATALOG_DOCUMENT, + remoteDpopTokens: [REMOTE_TOKEN], + }, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + + expect(removeConnectionFromCatalog(registered, BEARER_TARGET)).toEqual( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + ); + }); + + it("persists the normalized SSH profile beside its target", () => { + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }, + }); + const document = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new SshConnectionRegistration({ target, profile }), + ); + + expect(document.targets).toEqual([target]); + expect(document.profiles).toEqual([profile]); + expect(document.credentials).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts new file mode 100644 index 00000000000..0ba55dfa2fb --- /dev/null +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -0,0 +1,141 @@ +import * as Schema from "effect/Schema"; + +import { + type ConnectionRegistration, + ConnectionCredential, + ConnectionProfile, +} from "../connection/catalog.ts"; +import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; +import * as TokenStore from "../authorization/tokenStore.ts"; + +export const StoredConnectionCredential = Schema.Struct({ + connectionId: Schema.String, + credential: ConnectionCredential, +}); +export type StoredConnectionCredential = typeof StoredConnectionCredential.Type; + +export const ConnectionCatalogDocument = Schema.Struct({ + schemaVersion: Schema.Literal(1), + targets: Schema.Array(PersistedConnectionTarget), + profiles: Schema.Array(ConnectionProfile), + credentials: Schema.Array(StoredConnectionCredential), + remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), +}); +export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; + +export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({ + schemaVersion: 1, + targets: [], + profiles: [], + credentials: [], + remoteDpopTokens: [], +}); + +export function replaceCatalogValue( + values: ReadonlyArray, + key: (value: A) => string, + next: A, +): ReadonlyArray { + const nextKey = key(next); + return [...values.filter((value) => key(value) !== nextKey), next]; +} + +export function removeCatalogValue( + values: ReadonlyArray, + key: (value: A) => string, + removedKey: string, +): ReadonlyArray { + return values.filter((value) => key(value) !== removedKey); +} + +function connectionIdOf(target: ConnectionTarget): string | null { + switch (target._tag) { + case "PrimaryConnectionTarget": + case "RelayConnectionTarget": + return null; + case "BearerConnectionTarget": + case "SshConnectionTarget": + return target.connectionId; + } +} + +function removeConnectionMetadata( + document: ConnectionCatalogDocument, + target: ConnectionTarget, + removeRemoteToken: boolean, +): ConnectionCatalogDocument { + const connectionId = connectionIdOf(target); + return { + ...document, + targets: removeCatalogValue( + document.targets, + (value) => value.environmentId, + target.environmentId, + ), + profiles: + connectionId === null + ? document.profiles + : removeCatalogValue(document.profiles, (value) => value.connectionId, connectionId), + credentials: + connectionId === null + ? document.credentials + : removeCatalogValue(document.credentials, (value) => value.connectionId, connectionId), + remoteDpopTokens: removeRemoteToken + ? removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + target.environmentId, + ) + : document.remoteDpopTokens, + }; +} + +export function registerConnectionInCatalog( + document: ConnectionCatalogDocument, + registration: ConnectionRegistration, +): ConnectionCatalogDocument { + const target = registration.target; + const previous = document.targets.find( + (candidate) => candidate.environmentId === target.environmentId, + ); + const cleaned = + previous === undefined ? document : removeConnectionMetadata(document, previous, false); + const next: ConnectionCatalogDocument = { + ...cleaned, + targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target), + }; + + switch (registration._tag) { + case "RelayConnectionRegistration": + return next; + case "BearerConnectionRegistration": + return { + ...next, + profiles: replaceCatalogValue( + next.profiles, + (value) => value.connectionId, + registration.profile, + ), + credentials: replaceCatalogValue(next.credentials, (value) => value.connectionId, { + connectionId: registration.target.connectionId, + credential: registration.credential, + }), + }; + case "SshConnectionRegistration": + return { + ...next, + profiles: replaceCatalogValue( + next.profiles, + (value) => value.connectionId, + registration.profile, + ), + }; + } +} + +export function removeConnectionFromCatalog( + document: ConnectionCatalogDocument, + target: ConnectionTarget, +): ConnectionCatalogDocument { + return removeConnectionMetadata(document, target, true); +} diff --git a/packages/client-runtime/src/reconnectBackoff.test.ts b/packages/client-runtime/src/reconnectBackoff.test.ts deleted file mode 100644 index fb6bb415217..00000000000 --- a/packages/client-runtime/src/reconnectBackoff.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - DEFAULT_RECONNECT_BACKOFF, - getReconnectDelayMs, - type ReconnectBackoffConfig, -} from "./reconnectBackoff.ts"; - -describe("getReconnectDelayMs", () => { - it("returns exponential delays with default config", () => { - expect(getReconnectDelayMs(0)).toBe(1_000); - expect(getReconnectDelayMs(1)).toBe(2_000); - expect(getReconnectDelayMs(2)).toBe(4_000); - expect(getReconnectDelayMs(3)).toBe(8_000); - expect(getReconnectDelayMs(4)).toBe(16_000); - expect(getReconnectDelayMs(5)).toBe(32_000); - expect(getReconnectDelayMs(6)).toBe(64_000); - }); - - it("returns null when retry index exceeds maxRetries", () => { - expect(getReconnectDelayMs(7)).toBeNull(); - expect(getReconnectDelayMs(100)).toBeNull(); - }); - - it("returns null for negative indices", () => { - expect(getReconnectDelayMs(-1)).toBeNull(); - }); - - it("returns null for non-integer indices", () => { - expect(getReconnectDelayMs(1.5)).toBeNull(); - }); - - it("caps delay at maxDelayMs", () => { - const config: ReconnectBackoffConfig = { - initialDelayMs: 10_000, - backoffFactor: 10, - maxDelayMs: 30_000, - maxRetries: 5, - }; - - expect(getReconnectDelayMs(0, config)).toBe(10_000); - expect(getReconnectDelayMs(1, config)).toBe(30_000); // 100_000 capped to 30_000 - expect(getReconnectDelayMs(2, config)).toBe(30_000); // 1_000_000 capped to 30_000 - }); - - it("supports unlimited retries when maxRetries is null", () => { - const config: ReconnectBackoffConfig = { - ...DEFAULT_RECONNECT_BACKOFF, - maxRetries: null, - }; - - expect(getReconnectDelayMs(0, config)).toBe(1_000); - expect(getReconnectDelayMs(50, config)).toBe(64_000); // capped at maxDelayMs - expect(getReconnectDelayMs(100, config)).toBe(64_000); - }); -}); - -describe("DEFAULT_RECONNECT_BACKOFF", () => { - it("has sensible defaults", () => { - expect(DEFAULT_RECONNECT_BACKOFF.initialDelayMs).toBe(1_000); - expect(DEFAULT_RECONNECT_BACKOFF.backoffFactor).toBe(2); - expect(DEFAULT_RECONNECT_BACKOFF.maxDelayMs).toBe(64_000); - expect(DEFAULT_RECONNECT_BACKOFF.maxRetries).toBe(7); - }); -}); diff --git a/packages/client-runtime/src/reconnectBackoff.ts b/packages/client-runtime/src/reconnectBackoff.ts deleted file mode 100644 index 4f7ddd15a52..00000000000 --- a/packages/client-runtime/src/reconnectBackoff.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Configuration for exponential reconnect backoff. - */ -export interface ReconnectBackoffConfig { - /** Base delay in milliseconds before the first retry. */ - readonly initialDelayMs: number; - /** Multiplier applied per retry (exponential factor). */ - readonly backoffFactor: number; - /** Hard upper bound on delay in milliseconds. */ - readonly maxDelayMs: number; - /** Maximum number of retries (0-based). `null` means unlimited. */ - readonly maxRetries: number | null; -} - -/** - * Sensible defaults for WebSocket reconnect backoff. - * - * - 1 s initial delay, doubling each retry, capped at 64 s, up to 7 retries. - */ -export const DEFAULT_RECONNECT_BACKOFF: ReconnectBackoffConfig = { - initialDelayMs: 1_000, - backoffFactor: 2, - maxDelayMs: 64_000, - maxRetries: 7, -}; - -/** - * Calculate the reconnect delay for a given retry index using exponential - * backoff. Returns `null` when `retryIndex` exceeds the configured maximum. - */ -export function getReconnectDelayMs( - retryIndex: number, - config: ReconnectBackoffConfig = DEFAULT_RECONNECT_BACKOFF, -): number | null { - if (!Number.isInteger(retryIndex) || retryIndex < 0) { - return null; - } - - if (config.maxRetries !== null && retryIndex >= config.maxRetries) { - return null; - } - - return Math.min( - Math.round(config.initialDelayMs * config.backoffFactor ** retryIndex), - config.maxDelayMs, - ); -} diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts new file mode 100644 index 00000000000..6bdc7798fb2 --- /dev/null +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -0,0 +1,374 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import * as ManagedRelay from "./managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as Connectivity from "../connection/connectivity.ts"; +import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; +import * as RelayEnvironmentDiscovery from "./discovery.ts"; + +const environments = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Environment One", + endpoint: { + httpBaseUrl: "https://one.example.test", + wsBaseUrl: "wss://one.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", + }, + { + environmentId: EnvironmentId.make("environment-2"), + label: "Environment Two", + endpoint: { + httpBaseUrl: "https://two.example.test", + wsBaseUrl: "wss://two.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", + }, +] satisfies ReadonlyArray; + +function status( + environment: RelayClientEnvironmentRecord, + value: "online" | "offline", +): RelayEnvironmentStatusResponse { + return { + environmentId: environment.environmentId, + endpoint: environment.endpoint, + status: value, + checkedAt: "2026-06-01T00:00:00.000Z", + }; +} + +const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { + const networkStatus = yield* SubscriptionRef.make("online"); + const listCalls = yield* Ref.make(0); + const listFailure = yield* Ref.make(null); + const secondListCall = yield* Deferred.make(); + const clerkToken = yield* Ref.make("clerk-token"); + const wakeups = yield* SubscriptionRef.make<{ + readonly sequence: number; + readonly reason: "application-active" | "credentials-changed"; + }>({ + sequence: 0, + reason: "application-active", + }); + const statusRequests = yield* Ref.make( + new Map< + string, + Deferred.Deferred + >(), + ); + for (const environment of environments) { + const request = yield* Deferred.make< + RelayEnvironmentStatusResponse, + ManagedRelay.ManagedRelayClientError + >(); + yield* Ref.update(statusRequests, (current) => { + const next = new Map(current); + next.set(environment.environmentId, request); + return next; + }); + } + + const client = ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(listCalls, (current) => current + 1); + if (count >= 2) { + yield* Deferred.succeed(secondListCall, undefined); + } + const failure = yield* Ref.get(listFailure); + if (failure) { + return yield* failure; + } + return environments; + }), + getEnvironmentStatus: ({ environmentId }) => + Ref.get(statusRequests).pipe( + Effect.flatMap((requests) => Deferred.await(requests.get(environmentId)!)), + ), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + } satisfies ManagedRelay.ManagedRelayClient["Service"]); + const connectivity = Connectivity.Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + const layer = RelayEnvironmentDiscovery.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ManagedRelay.ManagedRelayClient, client), + Layer.succeed( + ClientCapabilities.CloudSession, + ClientCapabilities.CloudSession.of({ + clerkToken: Ref.get(clerkToken).pipe( + Effect.flatMap((token) => + token === null + ? Effect.fail( + new ConnectionBlockedError({ + reason: "authentication", + detail: "Signed out.", + }), + ) + : Effect.succeed(token), + ), + ), + }), + ), + Layer.succeed(Connectivity.Connectivity, connectivity), + Layer.succeed( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ + changes: SubscriptionRef.changes(wakeups).pipe( + Stream.drop(1), + Stream.map((event) => event.reason), + ), + }), + ), + ), + ), + ); + + return { + layer, + listCalls, + listFailure, + clerkToken, + networkStatus, + secondListCall, + statusRequests, + wake: (reason: "application-active" | "credentials-changed") => + SubscriptionRef.update(wakeups, (event) => ({ + sequence: event.sequence + 1, + reason, + })), + }; +}); + +describe("RelayEnvironmentDiscovery", () => { + it.effect("publishes each environment status as soon as that lookup completes", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const refreshFiber = yield* Effect.forkChild(discovery.refresh); + + const checking = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.environments.size === 2), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect( + [...checking.environments.values()].every((entry) => entry.availability === "checking"), + ).toBe(true); + + const requests = yield* Ref.get(harness.statusRequests); + yield* Deferred.succeed( + requests.get(environments[1]!.environmentId)!, + status(environments[1]!, "online"), + ); + + const partiallyResolved = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter( + (state) => + state.environments.get(environments[1]!.environmentId)?.availability === "online", + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect( + partiallyResolved.environments.get(environments[0]!.environmentId)?.availability, + ).toBe("checking"); + + yield* Deferred.succeed( + requests.get(environments[0]!.environmentId)!, + status(environments[0]!, "offline"), + ); + yield* Fiber.join(refreshFiber); + + const complete = yield* SubscriptionRef.get(discovery.state); + expect(complete.environments.get(environments[0]!.environmentId)?.availability).toBe( + "offline", + ); + expect(complete.refreshing).toBe(false); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect( + "preserves discovered rows while offline and refreshes after connectivity returns", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + yield* discovery.refresh; + + const offlineFiber = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.offline), + Stream.runHead, + Effect.forkChild, + ); + yield* SubscriptionRef.set(harness.networkStatus, "offline"); + yield* Fiber.join(offlineFiber); + expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2); + + yield* SubscriptionRef.set(harness.networkStatus, "online"); + yield* Deferred.await(harness.secondListCall); + expect(yield* Ref.get(harness.listCalls)).toBe(2); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("publishes listing failures without rejecting the refresh command", () => + Effect.gen(function* () { + const networkStatus = yield* SubscriptionRef.make("online"); + const client = ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => + Effect.fail( + new ManagedRelay.ManagedRelayRequestTimeoutError({ + activity: "Relay environment listing", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, + }), + ), + getEnvironmentStatus: () => Effect.die("unused"), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + } satisfies ManagedRelay.ManagedRelayClient["Service"]); + const layer = RelayEnvironmentDiscovery.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ManagedRelay.ManagedRelayClient, client), + Layer.succeed(ClientCapabilities.CloudSession, { + clerkToken: Effect.succeed("clerk-token"), + }), + Layer.succeed(Connectivity.Connectivity, { + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }), + Layer.succeed( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }), + ), + ), + ), + ); + + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + yield* discovery.refresh; + + const state = yield* SubscriptionRef.get(discovery.state); + expect(state.refreshing).toBe(false); + expect(Option.getOrThrow(state.error)).toMatchObject({ + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Relay environment listing timed out.", + }); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("clears previously discovered rows when a refresh fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + yield* discovery.refresh; + expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2); + + yield* Ref.set( + harness.listFailure, + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "list relay-managed environments", + cause: new Error("Relay request failed."), + }), + ); + yield* discovery.refresh; + + const failed = yield* SubscriptionRef.get(discovery.state); + expect(failed.environments.size).toBe(0); + expect(Option.isSome(failed.error)).toBe(true); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const refreshFiber = yield* Effect.forkChild(discovery.refresh); + yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.environments.size === environments.length), + Stream.runHead, + ); + + yield* Ref.set(harness.clerkToken, null); + yield* harness.wake("credentials-changed"); + yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.environments.size === 0), + Stream.runHead, + ); + + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + yield* Fiber.join(refreshFiber); + yield* Effect.yieldNow; + + expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(0); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); +}); diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts new file mode 100644 index 00000000000..8cbadea1ca5 --- /dev/null +++ b/packages/client-runtime/src/relay/discovery.ts @@ -0,0 +1,328 @@ +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; +import { + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import * as ManagedRelay from "./managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as Connectivity from "../connection/connectivity.ts"; +import { mapManagedRelayError } from "../connection/errors.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; + +export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error"; + +export interface RelayDiscoveredEnvironment { + readonly environment: RelayClientEnvironmentRecord; + readonly availability: RelayEnvironmentAvailability; + readonly status: Option.Option; + readonly error: Option.Option; +} + +export interface RelayEnvironmentDiscoveryState { + readonly environments: ReadonlyMap; + readonly refreshing: boolean; + readonly offline: boolean; + readonly error: Option.Option; +} + +export class RelayEnvironmentDiscovery extends Context.Service< + RelayEnvironmentDiscovery, + { + readonly state: SubscriptionRef.SubscriptionRef; + readonly refresh: Effect.Effect; + } +>()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {} + +export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = { + environments: new Map(), + refreshing: false, + offline: false, + error: Option.none(), +}; + +function validateStatus( + environment: RelayClientEnvironmentRecord, + status: RelayEnvironmentStatusResponse, +): Effect.Effect { + if (status.environmentId !== environment.environmentId) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + detail: "Relay returned status for a different environment.", + }), + ); + } + if ( + status.endpoint.httpBaseUrl !== environment.endpoint.httpBaseUrl || + status.endpoint.wsBaseUrl !== environment.endpoint.wsBaseUrl || + status.endpoint.providerKind !== environment.endpoint.providerKind + ) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + detail: "Relay returned status for a different environment endpoint.", + }), + ); + } + if ( + status.descriptor !== undefined && + status.descriptor.environmentId !== environment.environmentId + ) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + detail: "Relay returned a descriptor for a different environment.", + }), + ); + } + return Effect.succeed(status); +} + +function relayAccountId(clerkToken: string): Option.Option { + try { + return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe( + Option.filter((subject) => subject.length > 0), + ); + } catch { + return Option.none(); + } +} + +export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { + const relay = yield* ManagedRelay.ManagedRelayClient; + const session = yield* ClientCapabilities.CloudSession; + const connectivity = yield* Connectivity.Connectivity; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); + const refreshLock = yield* Semaphore.make(1); + const hasRefreshed = yield* Ref.make(false); + const accountGeneration = yield* Ref.make(0); + const activeAccountId = yield* Ref.make>(Option.none()); + const refreshGeneration = yield* Ref.make(0); + const offlineReportFingerprints = yield* Ref.make>(new Map()); + + const clearOfflineReport = Effect.fn("RelayEnvironmentDiscovery.clearOfflineReport")(function* ( + environmentId: string, + ) { + yield* Ref.update(offlineReportFingerprints, (current) => { + if (!current.has(environmentId)) { + return current; + } + const next = new Map(current); + next.delete(environmentId); + return next; + }); + }); + + const updateEnvironment = Effect.fn("RelayEnvironmentDiscovery.updateEnvironment")(function* ( + generation: number, + environmentId: string, + update: (current: RelayDiscoveredEnvironment) => RelayDiscoveredEnvironment, + ) { + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + yield* SubscriptionRef.update(state, (current) => { + const entry = current.environments.get(environmentId); + if (entry === undefined) { + return current; + } + const environments = new Map(current.environments); + environments.set(environmentId, update(entry)); + return { ...current, environments }; + }); + }); + + const refreshStatus = Effect.fn("RelayEnvironmentDiscovery.refreshStatus")(function* ( + generation: number, + clerkToken: string, + environment: RelayClientEnvironmentRecord, + ) { + const result = yield* relay + .getEnvironmentStatus({ + clerkToken, + scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + environmentId: environment.environmentId, + }) + .pipe( + Effect.mapError(mapManagedRelayError), + Effect.flatMap((status) => validateStatus(environment, status)), + Effect.result, + ); + + if (result._tag === "Success") { + if (result.success.status === "offline") { + const fingerprint = `${result.success.endpoint.httpBaseUrl}\n${result.success.error ?? ""}`; + const shouldReport = yield* Ref.modify(offlineReportFingerprints, (current) => { + if (current.get(environment.environmentId) === fingerprint) { + return [false, current]; + } + return [true, new Map(current).set(environment.environmentId, fingerprint)]; + }); + if (shouldReport) { + yield* Effect.logWarning("Relay environment health check reported offline", { + environmentId: result.success.environmentId, + endpoint: result.success.endpoint.httpBaseUrl, + message: result.success.error, + traceId: result.success.traceId, + }); + } + } else { + yield* clearOfflineReport(environment.environmentId); + } + yield* updateEnvironment(generation, environment.environmentId, (current) => ({ + ...current, + availability: result.success.status, + status: Option.some(result.success), + error: Option.none(), + })); + return; + } + + yield* clearOfflineReport(environment.environmentId); + yield* updateEnvironment(generation, environment.environmentId, (current) => ({ + ...current, + availability: "error", + error: Option.some(result.failure), + })); + }); + + const refresh = refreshLock.withPermits(1)( + Effect.gen(function* () { + yield* Ref.set(hasRefreshed, true); + if ((yield* connectivity.status) === "offline") { + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + offline: true, + })); + return; + } + + let generation = yield* Ref.get(accountGeneration); + yield* Ref.set(refreshGeneration, generation); + yield* SubscriptionRef.set(state, { + environments: new Map(), + refreshing: true, + offline: false, + error: Option.none(), + }); + + const clerkToken = yield* session.clerkToken; + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + const accountId = relayAccountId(clerkToken); + const previousAccountId = yield* Ref.get(activeAccountId); + if ( + Option.isSome(previousAccountId) && + (!Option.isSome(accountId) || previousAccountId.value !== accountId.value) + ) { + generation = yield* Ref.updateAndGet(accountGeneration, (current) => current + 1); + yield* Ref.set(refreshGeneration, generation); + } + yield* Ref.set(activeAccountId, accountId); + + const environments = yield* relay + .listEnvironments({ clerkToken }) + .pipe(Effect.mapError(mapManagedRelayError)); + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + const next = new Map(); + for (const environment of environments) { + next.set(environment.environmentId, { + environment, + availability: "checking", + status: Option.none(), + error: Option.none(), + }); + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + environments: next, + })); + + yield* Effect.forEach( + environments, + (environment) => refreshStatus(generation, clerkToken, environment), + { + concurrency: "unbounded", + discard: true, + }, + ); + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + })); + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const generation = yield* Ref.get(refreshGeneration); + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + error: Option.some(error), + })); + }), + ), + ), + ); + + yield* connectivity.changes.pipe( + Stream.changes, + Stream.runForEach((networkStatus) => + networkStatus === "offline" + ? SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + offline: true, + })) + : Ref.get(hasRefreshed).pipe( + Effect.flatMap((shouldRefresh) => (shouldRefresh ? refresh : Effect.void)), + ), + ), + Effect.forkScoped, + ); + yield* wakeups.changes.pipe( + Stream.runForEach((reason) => + reason === "credentials-changed" + ? Effect.gen(function* () { + yield* Ref.update(accountGeneration, (current) => current + 1); + yield* Ref.set(activeAccountId, Option.none()); + yield* Ref.set(offlineReportFingerprints, new Map()); + const shouldRefresh = yield* Ref.get(hasRefreshed); + yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); + if (shouldRefresh) { + yield* refresh.pipe(Effect.forkScoped); + } + }) + : Effect.void, + ), + Effect.forkScoped, + ); + + return RelayEnvironmentDiscovery.of({ state, refresh }); +}); + +export const layer = Layer.effect(RelayEnvironmentDiscovery, make()); diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts new file mode 100644 index 00000000000..76f75535304 --- /dev/null +++ b/packages/client-runtime/src/relay/index.ts @@ -0,0 +1,3 @@ +export * as Discovery from "./discovery.ts"; +export * as ManagedRelay from "./managedRelay.ts"; +export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts new file mode 100644 index 00000000000..278c205883f --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -0,0 +1,511 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ManagedRelay from "./managedRelay.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; + +function managedRelayTestLayer( + fetchFn: typeof globalThis.fetch, + relayUrl = "https://relay.example.test", + accessTokenStore?: ManagedRelay.ManagedRelayAccessTokenStore, +) { + const httpClientLayer = remoteHttpClientLayer(fetchFn); + const signerLayer = Layer.succeed( + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("client-thumbprint"), + createProof: (input: ManagedRelay.ManagedRelayDpopProofInput) => + Effect.succeed(`proof:${input.url}`), + }), + ); + return ManagedRelay.layer({ + relayUrl, + clientId: "t3-mobile", + ...(accessTokenStore ? { accessTokenStore } : {}), + }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); +} + +function clerkToken(subject: string, nonce: string): string { + const encode = (value: unknown) => + btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + return `${encode({ alg: "none" })}.${encode({ sub: subject, nonce })}.signature`; +} + +describe("ManagedRelayClient", () => { + it.effect("owns tracing at service and implementation boundaries", () => { + const spanNames: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spanNames.push(span.name); + return span; + }, + }); + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json({ + access_token: "relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + yield* relayClient.getEnvironmentStatus({ + clerkToken: clerkToken("user-1", "session-1"), + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + + expect(spanNames).toEqual( + expect.arrayContaining([ + "clientRuntime.managedRelay.getEnvironmentStatus", + "clientRuntime.managedRelay.authorize", + "clientRuntime.managedRelay.obtainAccessToken", + "clientRuntime.managedRelay.tokenCacheCriticalSection", + "clientRuntime.managedRelay.exchangeAccessToken", + ]), + ); + expect(spanNames).not.toEqual( + expect.arrayContaining([ + "clientRuntime.managedRelay.createTokenExchangeProof", + "clientRuntime.managedRelay.exchangeAccessTokenRequest", + "clientRuntime.managedRelay.createRequestProof", + ]), + ); + }).pipe(Effect.withTracer(tracer), Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("rejects unsafe relay URLs before sending credentials", () => { + let requestCount = 0; + const fetchFn = (() => { + requestCount += 1; + return Promise.resolve(Response.json({})); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayUrlInvalidError", + relayUrl: "http://relay.example.test", + message: "Relay URL must be a secure absolute HTTPS origin.", + }); + expect(requestCount).toBe(0); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); + }); + + it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { + let tokenExchangeCount = 0; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: `relay-token-${tokenExchangeCount}`, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 10, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const statusInput = { + clerkToken: clerkToken("user-1", "session-1"), + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + } as const; + + yield* relayClient.getEnvironmentStatus(statusInput); + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(1); + + yield* TestClock.adjust(Duration.seconds(6)); + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(2); + + yield* relayClient.resetTokenCache; + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(3); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => { + let tokenExchangeCount = 0; + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { + load: Effect.sync(() => persistedTokens), + save: (entries) => + Effect.sync(() => { + persistedTokens = entries; + }), + clear: Effect.sync(() => { + persistedTokens = []; + }), + }; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: "persisted-relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + const statusInput = (token: string) => + ({ + clerkToken: token, + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }) as const; + + return Effect.gen(function* () { + yield* Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1"))); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + + expect(tokenExchangeCount).toBe(1); + expect(persistedTokens).toHaveLength(1); + + yield* Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2"))); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + + expect(tokenExchangeCount).toBe(1); + }); + }); + + it.effect("refreshes a persisted DPoP token once when the relay rejects it", () => { + let tokenExchangeCount = 0; + const statusTokens: Array = []; + let persistedTokens: ReadonlyArray = [ + { + accountId: "user-1", + clientId: "t3-mobile", + relayUrl: "https://relay.example.test", + thumbprint: "client-thumbprint", + scopes: [RelayEnvironmentStatusScope], + accessToken: "stale-relay-token", + expiresAtMillis: Number.MAX_SAFE_INTEGER, + }, + ]; + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { + load: Effect.sync(() => persistedTokens), + save: (entries) => + Effect.sync(() => { + persistedTokens = entries; + }), + clear: Effect.sync(() => { + persistedTokens = []; + }), + }; + const fetchFn = ((input, init) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: "fresh-relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + + const authorization = new Headers(init?.headers).get("authorization"); + statusTokens.push(authorization); + if (authorization === "DPoP stale-relay-token") { + return Promise.resolve( + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-stale-token", + }, + { status: 401 }, + ), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const result = yield* relayClient.getEnvironmentStatus({ + clerkToken: clerkToken("user-1", "session-1"), + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + + expect(result.status).toBe("online"); + expect(statusTokens).toEqual(["DPoP stale-relay-token", "DPoP fresh-relay-token"]); + expect(tokenExchangeCount).toBe(1); + expect(persistedTokens).toMatchObject([ + { + accessToken: "fresh-relay-token", + }, + ]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + }); + + it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => { + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { + load: Effect.succeed([]), + save: (entries) => + Effect.sync(() => { + persistedTokens = entries; + }), + clear: Effect.void, + }; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json({ + access_token: "relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + yield* relayClient.getEnvironmentStatus({ + clerkToken: "not-a-jwt", + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + + expect(persistedTokens).toEqual([]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + }); + + it.effect("times out stalled relay environment listing requests", () => { + const fetchFn = (() => + new Promise(() => undefined)) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const errorFiber = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS)); + const error = yield* Fiber.join(errorFiber); + + expect(error).toMatchObject({ + _tag: "ManagedRelayRequestTimeoutError", + activity: "Relay environment listing", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, + message: "Relay environment listing timed out.", + }); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); + }); + + it.effect("preserves typed relay trace IDs on client errors", () => { + const fetchFn = (() => + Promise.resolve( + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-managed-relay", + }, + { status: 401 }, + ), + )) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayRequestFailedError", + traceId: "trace-managed-relay", + }); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("lists account devices through the Clerk bearer client endpoint", () => { + const fetchFn = ((input, init) => { + expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); + expect(init?.headers).toMatchObject({ + authorization: "Bearer clerk-token", + }); + return Promise.resolve( + Response.json({ + devices: [ + { + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + notifications: { + enabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ], + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); + expect(devices).toMatchObject([ + { + deviceId: "device-1", + label: "Julius's iPhone", + notifications: { + enabled: false, + }, + }, + ]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); +}); diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts new file mode 100644 index 00000000000..08b720b46a3 --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -0,0 +1,879 @@ +import { + RelayAccessTokenType, + RelayApi, + type RelayClientEnvironmentRecord, + type RelayClientDeviceRecord, + RelayConnectEnvironmentEndpoint, + type RelayDeviceRegistrationRequest, + RelayDpopAccessTokenScope, + RelayDpopTokenExchangeGrantType, + type RelayEnvironmentConnectRequest, + type RelayEnvironmentConnectResponse, + type RelayEnvironmentLinkChallengeRequest, + type RelayEnvironmentLinkChallengeResponse, + type RelayEnvironmentLinkRequest, + type RelayEnvironmentLinkResponse, + type RelayEnvironmentStatusResponse, + RelayExchangeDpopAccessTokenEndpoint, + RelayGetEnvironmentStatusEndpoint, + RelayJwtSubjectTokenType, + type RelayLiveActivityRegistrationRequest, + RelayMobileRegistrationScope, + type RelayOkResponse, + type RelayPublicClientId, + RelayRegisterDeviceEndpoint, + RelayRegisterLiveActivityEndpoint, + RelayProtectedError, + type RelayProtectedError as RelayProtectedErrorType, + RelayUnregisterDeviceEndpoint, +} from "@t3tools/contracts/relay"; +import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; +import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpMethod from "effect/unstable/http/HttpMethod"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +export interface ManagedRelayDpopProofInput { + readonly method: HttpMethod.HttpMethod; + readonly url: string; + readonly accessToken?: string; +} + +export class ManagedRelayDpopKeyLoadError extends Schema.TaggedErrorClass()( + "ManagedRelayDpopKeyLoadError", + { + keyStore: Schema.Literals(["expo-secure-store", "indexed-db"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not load relay DPoP proof key."; + } +} + +export class ManagedRelayDpopProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayDpopProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not create the relay DPoP proof for ${this.method} ${this.url}.`; + } +} + +export const ManagedRelayDpopSignerError = Schema.Union([ + ManagedRelayDpopKeyLoadError, + ManagedRelayDpopProofCreationError, +]); +export type ManagedRelayDpopSignerError = typeof ManagedRelayDpopSignerError.Type; + +export const ManagedRelayRequestAction = Schema.Literals([ + "exchange relay DPoP access token", + "list relay-managed environments", + "list relay client devices", + "create relay environment link challenge", + "link relay environment", + "unlink relay environment", + "get relay environment status", + "connect relay environment", + "register relay mobile device", + "unregister relay mobile device", + "register relay live activity", +]); +export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; + +export const ManagedRelayRequestActivity = Schema.Literals([ + "Relay DPoP access token exchange", + "Relay environment listing", + "Relay client device listing", + "Relay environment link challenge", + "Relay environment linking", + "Relay environment unlinking", + "Relay environment status request", + "Relay environment connection", + "Relay mobile device registration", + "Relay mobile device unregistration", + "Relay Live Activity registration", +]); +export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; + +export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass()( + "ManagedRelayRequestTimeoutError", + { + activity: ManagedRelayRequestActivity, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `${this.activity} timed out.`; + } +} + +export class ManagedRelayUrlInvalidError extends Schema.TaggedErrorClass()( + "ManagedRelayUrlInvalidError", + { + relayUrl: Schema.String, + }, +) { + override get message(): string { + return "Relay URL must be a secure absolute HTTPS origin."; + } +} + +export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass()( + "ManagedRelayRequestFailedError", + { + action: ManagedRelayRequestAction, + cause: Schema.Defect(), + relayError: Schema.optional(RelayProtectedError), + traceId: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `Could not ${this.action}.`; + } +} + +export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedErrorClass()( + "ManagedRelayAccessTokenScopesUnexpectedError", + { + requestedScopes: Schema.Array(RelayDpopAccessTokenScope), + grantedScope: Schema.String, + }, +) { + override get message(): string { + return "Relay granted unexpected DPoP access token scopes."; + } +} + +export class ManagedRelayTokenProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayTokenProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not create relay token DPoP proof."; + } +} + +export class ManagedRelayRequestProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayRequestProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not create relay request DPoP proof."; + } +} + +export const ManagedRelayClientError = Schema.Union([ + ManagedRelayUrlInvalidError, + ManagedRelayRequestFailedError, + ManagedRelayRequestTimeoutError, + ManagedRelayAccessTokenScopesUnexpectedError, + ManagedRelayDpopKeyLoadError, + ManagedRelayTokenProofCreationError, + ManagedRelayRequestProofCreationError, +]); +export type ManagedRelayClientError = typeof ManagedRelayClientError.Type; + +type RelayHttpRequestError = + | RelayProtectedErrorType + | HttpClientError.HttpClientError + | Schema.SchemaError; + +export class ManagedRelayDpopSigner extends Context.Service< + ManagedRelayDpopSigner, + { + readonly thumbprint: Effect.Effect; + readonly createProof: ( + input: ManagedRelayDpopProofInput, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {} + +export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; + +export interface ManagedRelayAccessTokenCacheEntry { + readonly accountId: string; + readonly clientId: RelayPublicClientId; + readonly relayUrl: string; + readonly thumbprint: string; + readonly scopes: ReadonlyArray; + readonly accessToken: string; + readonly expiresAtMillis: number; +} + +export interface ManagedRelayAccessTokenStore { + readonly load: Effect.Effect>; + readonly save: (entries: ReadonlyArray) => Effect.Effect; + readonly clear: Effect.Effect; +} + +export interface ManagedRelayAuthorization { + readonly accessToken: string; + readonly proof: string; + readonly thumbprint: string; +} + +export interface ManagedRelayClientLayerOptions { + readonly relayUrl: string; + readonly clientId: RelayPublicClientId; + readonly accessTokenStore?: ManagedRelayAccessTokenStore; +} + +export class ManagedRelayClient extends Context.Service< + ManagedRelayClient, + { + readonly relayUrl: string; + readonly listEnvironments: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly listDevices: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly createEnvironmentLinkChallenge: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkChallengeRequest; + }) => Effect.Effect; + readonly linkEnvironment: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkRequest; + }) => Effect.Effect; + readonly unlinkEnvironment: (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly getEnvironmentStatus: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly connectEnvironment: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly deviceId?: string; + }) => Effect.Effect; + readonly registerDevice: (input: { + readonly clerkToken: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregisterDevice: (input: { + readonly clerkToken: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly registerLiveActivity: (input: { + readonly clerkToken: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly resetTokenCache: Effect.Effect; + } +>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} + +const isRelayProtectedError = Schema.is(RelayProtectedError); + +function relayRequestError(action: ManagedRelayRequestAction) { + return (cause: RelayHttpRequestError): ManagedRelayClientError => + new ManagedRelayRequestFailedError({ + action, + cause, + ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}), + }); +} + +function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) { + return { + method: error.method, + url: error.url, + cause: error, + }; +} + +function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean { + return ( + error._tag === "ManagedRelayRequestFailedError" && + error.relayError?._tag === "RelayAuthInvalidError" && + error.relayError.reason === "invalid_bearer" + ); +} + +function timeoutRelayRequest(activity: ManagedRelayRequestActivity) { + return ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new ManagedRelayRequestTimeoutError({ + activity, + timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, + }), + ), + onSome: Effect.succeed, + }), + ), + ); +} + +function tokenMatches( + token: ManagedRelayAccessTokenCacheEntry, + input: { + readonly accountId: string; + readonly clientId: RelayPublicClientId; + readonly relayUrl: string; + readonly thumbprint: string; + readonly scopes: ReadonlyArray; + readonly nowMillis: number; + }, +): boolean { + return ( + token.accountId === input.accountId && + token.clientId === input.clientId && + token.relayUrl === input.relayUrl && + token.thumbprint === input.thumbprint && + token.expiresAtMillis > input.nowMillis + 5_000 && + input.scopes.every((scope) => token.scopes.includes(scope)) + ); +} + +function relayAccountId(clerkToken: string): Option.Option { + try { + return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe( + Option.filter((subject) => subject.length > 0), + ); + } catch { + return Option.none(); + } +} + +function bearerHeaders(clerkToken: string) { + return { authorization: `Bearer ${clerkToken}` }; +} + +function dpopHeaders(authorization: ManagedRelayAuthorization) { + return { + authorization: `DPoP ${authorization.accessToken}`, + dpop: authorization.proof, + }; +} + +function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Service"] { + const unavailable = (spanName: string) => + Effect.fn(spanName)(function* () { + return yield* new ManagedRelayUrlInvalidError({ relayUrl }); + }); + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: unavailable("clientRuntime.managedRelay.listEnvironments"), + listDevices: unavailable("clientRuntime.managedRelay.listDevices"), + createEnvironmentLinkChallenge: unavailable( + "clientRuntime.managedRelay.createEnvironmentLinkChallenge", + ), + linkEnvironment: unavailable("clientRuntime.managedRelay.linkEnvironment"), + unlinkEnvironment: unavailable("clientRuntime.managedRelay.unlinkEnvironment"), + getEnvironmentStatus: unavailable("clientRuntime.managedRelay.getEnvironmentStatus"), + connectEnvironment: unavailable("clientRuntime.managedRelay.connectEnvironment"), + registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), + unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), + registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), + resetTokenCache: Effect.void.pipe( + Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), + ), + }); +} + +export const make = Effect.fn("ManagedRelayClient.make")(function* ( + options: ManagedRelayClientLayerOptions, +) { + const relayUrl = normalizeSecureRelayUrl(options.relayUrl); + if (relayUrl === null) { + return disabledManagedRelayClient(options.relayUrl); + } + const signer = yield* ManagedRelayDpopSigner; + const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); + const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; + const cachedTokens = yield* SynchronizedRef.make< + ReadonlyArray + >(initialTokens.filter((token) => token.clientId === options.clientId)); + const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); + + type DpopProofTarget = Pick; + const dpopProofTargets = { + exchangeAccessToken: (): DpopProofTarget => ({ + method: RelayExchangeDpopAccessTokenEndpoint.method, + url: urlBuilder.token.exchangeDpopAccessToken(), + }), + getEnvironmentStatus: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayGetEnvironmentStatusEndpoint.method, + url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), + }), + connectEnvironment: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayConnectEnvironmentEndpoint.method, + url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), + }), + registerDevice: (): DpopProofTarget => ({ + method: RelayRegisterDeviceEndpoint.method, + url: urlBuilder.mobile.registerDevice(), + }), + unregisterDevice: (deviceId: string): DpopProofTarget => ({ + method: RelayUnregisterDeviceEndpoint.method, + url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), + }), + registerLiveActivity: (): DpopProofTarget => ({ + method: RelayRegisterLiveActivityEndpoint.method, + url: urlBuilder.mobile.registerLiveActivity(), + }), + }; + + const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const proof = yield* signer + .createProof(dpopProofTargets.exchangeAccessToken()) + .pipe( + Effect.mapError( + (error) => new ManagedRelayTokenProofCreationError(proofCreationErrorFields(error)), + ), + ); + const response = yield* client.token + .exchangeDpopAccessToken({ + headers: { dpop: proof }, + payload: { + grant_type: RelayDpopTokenExchangeGrantType, + subject_token: input.clerkToken, + subject_token_type: RelayJwtSubjectTokenType, + requested_token_type: RelayAccessTokenType, + resource: relayUrl, + scope: encodeOAuthScope(input.scopes), + client_id: options.clientId, + }, + }) + .pipe( + Effect.mapError(relayRequestError("exchange relay DPoP access token")), + timeoutRelayRequest("Relay DPoP access token exchange"), + ); + if (!oauthScopeSetEquals(response.scope, input.scopes)) { + return yield* new ManagedRelayAccessTokenScopesUnexpectedError({ + requestedScopes: input.scopes, + grantedScope: response.scope, + }); + } + return response; + }, + ); + + const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly thumbprint: string; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const nowMillis = yield* Clock.currentTimeMillis; + const accountId = relayAccountId(input.clerkToken); + if (Option.isNone(accountId)) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "bypass", + "relay.token_cache.bypass_reason": "invalid_subject_token", + }); + const response = yield* exchangeAccessToken(input); + return { + accountId: "", + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + } satisfies ManagedRelayAccessTokenCacheEntry; + } + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => + Effect.gen(function* () { + const activeTokens = tokens.filter((token) => token.expiresAtMillis > nowMillis + 5_000); + const cached = activeTokens.find((token) => + tokenMatches(token, { + accountId: accountId.value, + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + nowMillis, + }), + ); + if (cached) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "hit", + }); + return [cached, activeTokens] as const; + } + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "miss", + }); + const response = yield* exchangeAccessToken(input); + const next: ManagedRelayAccessTokenCacheEntry = { + accountId: accountId.value, + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + }; + const nextTokens = [...activeTokens, next]; + if (options.accessTokenStore) { + yield* options.accessTokenStore.save(nextTokens); + } + return [next, nextTokens] as const; + }), + ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); + }, + ); + + const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + "http.request.method": input.target.method, + "url.full": input.target.url, + }); + const thumbprint = yield* signer.thumbprint; + const token = yield* obtainAccessToken({ + clerkToken: input.clerkToken, + scopes: input.scopes, + thumbprint, + }); + const proof = yield* signer + .createProof({ + ...input.target, + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError( + (error) => new ManagedRelayRequestProofCreationError(proofCreationErrorFields(error)), + ), + ); + return { accessToken: token.accessToken, proof, thumbprint }; + }); + + const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")( + function* (accessToken: string) { + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { + const nextTokens = tokens.filter((token) => token.accessToken !== accessToken); + if (nextTokens.length === tokens.length) { + return Effect.succeed([false, tokens] as const); + } + return ( + options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void + ).pipe(Effect.as([true, nextTokens] as const)); + }); + }, + ); + + const runDpopRequest = ( + input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ): Effect.Effect => { + const attempt = (refreshRejectedToken: boolean): Effect.Effect => + authorize(input).pipe( + Effect.flatMap((authorization) => + request(authorization).pipe( + Effect.catch((error) => { + if (!isRejectedDpopAccessToken(error)) { + return Effect.fail(error); + } + return invalidateAccessToken(authorization.accessToken).pipe( + Effect.tap((invalidated) => + Effect.annotateCurrentSpan({ + "relay.token_cache.invalidated": invalidated, + "relay.token_cache.invalidation_reason": "invalid_bearer", + "relay.token_cache.retry_after_invalidation": refreshRejectedToken, + }), + ), + Effect.tap((invalidated) => + invalidated && refreshRejectedToken + ? Effect.logWarning( + "Relay rejected a cached DPoP access token; refreshing it once.", + ) + : Effect.void, + ), + Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), + ); + }), + ), + ), + ); + return attempt(true); + }; + + const mobileRegistrationRequest = ( + input: { + readonly clerkToken: string; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ) => + runDpopRequest( + { + ...input, + scopes: [RelayMobileRegistrationScope], + }, + request, + ); + + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.map((response) => response.environments), + Effect.mapError(relayRequestError("list relay-managed environments")), + timeoutRelayRequest("Relay environment listing"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), + withRelayClientTracing, + ), + listDevices: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listDevices({ + headers: bearerHeaders(input.clerkToken), + }) + .pipe( + Effect.map((response) => response.devices), + Effect.mapError(relayRequestError("list relay client devices")), + timeoutRelayRequest("Relay client device listing"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.listDevices"), + withRelayClientTracing, + ), + createEnvironmentLinkChallenge: Effect.fnUntraced( + function* (input) { + return yield* client.client + .createEnvironmentLinkChallenge({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("create relay environment link challenge")), + timeoutRelayRequest("Relay environment link challenge"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), + withRelayClientTracing, + ), + linkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .linkEnvironment({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("link relay environment")), + timeoutRelayRequest("Relay environment linking"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), + withRelayClientTracing, + ), + unlinkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .unlinkEnvironment({ + headers: bearerHeaders(input.clerkToken), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("unlink relay environment")), + timeoutRelayRequest("Relay environment unlinking"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), + withRelayClientTracing, + ), + getEnvironmentStatus: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( + { + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.getEnvironmentStatus(input.environmentId), + }, + (authorization) => + client.dpopClient + .getEnvironmentStatus({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("get relay environment status")), + timeoutRelayRequest("Relay environment status request"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), + withRelayClientTracing, + ), + connectEnvironment: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( + { + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.connectEnvironment(input.environmentId), + }, + (authorization) => { + const payload: RelayEnvironmentConnectRequest = { + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + clientKeyThumbprint: authorization.thumbprint, + }; + return client.dpopClient + .connectEnvironment({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + payload, + }) + .pipe( + Effect.mapError(relayRequestError("connect relay environment")), + timeoutRelayRequest("Relay environment connection"), + ); + }, + ); + }, + Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), + withRelayClientTracing, + ), + registerDevice: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerDevice(), + }, + (authorization) => + client.mobile + .registerDevice({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("register relay mobile device")), + timeoutRelayRequest("Relay mobile device registration"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerDevice"), + withRelayClientTracing, + ), + unregisterDevice: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.unregisterDevice(input.deviceId), + }, + (authorization) => + client.mobile + .unregisterDevice({ + headers: dpopHeaders(authorization), + params: { deviceId: input.deviceId }, + }) + .pipe( + Effect.mapError(relayRequestError("unregister relay mobile device")), + timeoutRelayRequest("Relay mobile device unregistration"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), + withRelayClientTracing, + ), + registerLiveActivity: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerLiveActivity(), + }, + (authorization) => + client.mobile + .registerLiveActivity({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("register relay live activity")), + timeoutRelayRequest("Relay Live Activity registration"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), + withRelayClientTracing, + ), + resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( + Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), + Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), + withRelayClientTracing, + ), + }); +}); + +export const layer = (options: ManagedRelayClientLayerOptions) => + Layer.effect(ManagedRelayClient, make(options)); diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts new file mode 100644 index 00000000000..49400d32aef --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -0,0 +1,380 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientDeviceRecord, + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, vi } from "vite-plus/test"; + +import * as ManagedRelay from "./managedRelay.ts"; +import { + createManagedRelayQueryManager, + createManagedRelaySession, + managedRelayAccountChanges, + type ManagedRelayQueryEvent, + managedRelaySessionAtom, + readManagedRelaySnapshotState, + setManagedRelaySession, + waitForManagedRelayClerkToken, +} from "./managedRelayState.ts"; + +let registry = AtomRegistry.make(); + +const environment = { + environmentId: EnvironmentId.make("environment-1"), + label: "Main environment", + endpoint: { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", +} satisfies RelayClientEnvironmentRecord; + +const device = { + deviceId: "device-1", + label: "Julius iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: null, + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", +} satisfies RelayClientDeviceRecord; + +function resetRegistry() { + registry.dispose(); + registry = AtomRegistry.make(); +} + +function createManager( + overrides?: Partial, + onQueryEvent?: (event: ManagedRelayQueryEvent) => void, +) { + const client = ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => Effect.succeed([environment]), + listDevices: () => Effect.succeed([device]), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + getEnvironmentStatus: () => + Effect.succeed({ + environmentId: environment.environmentId, + endpoint: environment.endpoint, + status: "online", + checkedAt: "2026-06-01T00:00:00.000Z", + }), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + ...overrides, + }); + const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client)); + return createManagedRelayQueryManager(runtime, { + staleTimeMs: 60_000, + ...(onQueryEvent ? { onQueryEvent } : {}), + }); +} + +function setSession() { + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("clerk-token"), + }); +} + +function clerkToken(expiresAtSeconds: number): string { + const encode = (value: unknown) => + btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); + return `${encode({ alg: "none" })}.${encode({ exp: expiresAtSeconds })}.signature`; +} + +describe("createManagedRelayQueryManager", () => { + afterEach(resetRegistry); + + it.effect("waits for the current cloud session before reading its token", () => + Effect.gen(function* () { + const tokenFiber = yield* waitForManagedRelayClerkToken(registry).pipe(Effect.forkChild); + + setSession(); + + expect(yield* Fiber.join(tokenFiber)).toBe("clerk-token"); + expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); + }), + ); + + it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => + Effect.gen(function* () { + const token = clerkToken(4_102_444_800); + let resolveToken!: (value: string) => void; + const readClerkToken = vi.fn( + () => + new Promise((resolve) => { + resolveToken = resolve; + }), + ); + const session = createManagedRelaySession({ + accountId: "account-1", + readClerkToken, + }); + + const readsFiber = yield* Effect.all([session.readClerkToken(), session.readClerkToken()], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(readClerkToken).toHaveBeenCalledTimes(1); + + resolveToken(token); + expect(yield* Fiber.join(readsFiber)).toEqual([token, token]); + expect(yield* session.readClerkToken()).toBe(token); + expect(readClerkToken).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("updates the token provider without replacing a same-account session", () => + Effect.gen(function* () { + const firstRead = vi.fn(() => Promise.resolve(null)); + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: firstRead, + }); + const firstSession = registry.get(managedRelaySessionAtom); + expect(firstSession).not.toBeNull(); + expect(yield* firstSession!.readClerkToken()).toBeNull(); + + const secondRead = vi.fn(() => Promise.resolve("refreshed-token")); + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: secondRead, + }); + + expect(registry.get(managedRelaySessionAtom)).toBe(firstSession); + expect(yield* firstSession!.readClerkToken()).toBe("refreshed-token"); + expect(firstRead).toHaveBeenCalledTimes(1); + expect(secondRead).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("does not pin a refreshed session to an older pending token read", () => + Effect.gen(function* () { + let resolveFirst!: (token: string) => void; + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + }); + const session = registry.get(managedRelaySessionAtom); + const firstRead = yield* session!.readClerkToken().pipe(Effect.forkChild); + yield* Effect.yieldNow; + + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("refreshed-token"), + }); + + expect(yield* session!.readClerkToken()).toBe("refreshed-token"); + resolveFirst("older-token"); + expect(yield* Fiber.join(firstRead)).toBe("older-token"); + }), + ); + + it("emits credential changes only when the managed relay account changes", async () => { + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("first-token"), + }); + const changes = Effect.runPromise( + managedRelayAccountChanges(registry).pipe(Stream.take(2), Stream.runCollect), + ); + await vi.waitFor(() => { + expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(0); + }); + + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("refreshed-token"), + }); + setManagedRelaySession(registry, { + accountId: "account-2", + readClerkToken: () => Promise.resolve("second-token"), + }); + setManagedRelaySession(registry, null); + + expect(Array.from(await changes)).toEqual(["account-2", null]); + }); + + it("shares one Clerk token read across concurrent relay list and status queries", async () => { + const secondEnvironment = { + ...environment, + environmentId: EnvironmentId.make("environment-2"), + label: "Second environment", + endpoint: { + ...environment.endpoint, + httpBaseUrl: "https://environment-2.example.test", + wsBaseUrl: "wss://environment-2.example.test", + }, + } satisfies RelayClientEnvironmentRecord; + const token = clerkToken(4_102_444_800); + const readClerkToken = vi.fn(() => Promise.resolve(token)); + const manager = createManager({ + listEnvironments: () => Effect.succeed([environment, secondEnvironment]), + getEnvironmentStatus: ({ environmentId }) => { + const current = + environmentId === environment.environmentId ? environment : secondEnvironment; + return Effect.succeed({ + environmentId: current.environmentId, + endpoint: current.endpoint, + status: "online" as const, + checkedAt: "2026-06-01T00:00:00.000Z", + }); + }, + }); + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken, + }); + + const environmentsAtom = manager.environmentsAtom("account-1"); + const firstStatusAtom = manager.environmentStatusAtom({ + accountId: "account-1", + environment, + }); + const secondStatusAtom = manager.environmentStatusAtom({ + accountId: "account-1", + environment: secondEnvironment, + }); + registry.get(environmentsAtom); + registry.get(firstStatusAtom); + registry.get(secondStatusAtom); + + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(firstStatusAtom)).data?.status).toBe( + "online", + ); + expect(readManagedRelaySnapshotState(registry.get(secondStatusAtom)).data?.status).toBe( + "online", + ); + }); + expect(readClerkToken).toHaveBeenCalledTimes(1); + }); + + it("keeps environment snapshots cached and refreshes them explicitly", async () => { + const listEnvironments = vi.fn(() => Effect.succeed([environment])); + const manager = createManager({ listEnvironments }); + setSession(); + const atom = manager.environmentsAtom("account-1"); + + registry.get(atom); + await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); + + registry.get(manager.environmentsAtom("account-1")); + expect(listEnvironments).toHaveBeenCalledTimes(1); + + manager.refreshEnvironments(registry, "account-1"); + await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); + }); + + it("loads device snapshots through the current account session", async () => { + const listDevices = vi.fn(() => Effect.succeed([device])); + const manager = createManager({ listDevices }); + setSession(); + const atom = manager.devicesAtom("account-1"); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); + }); + }); + + it("reports token and relay request phases for environment status queries", async () => { + const onQueryEvent = vi.fn(); + const manager = createManager(undefined, onQueryEvent); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).data?.status).toBe("online"); + }); + + expect(onQueryEvent).toHaveBeenCalledWith({ + operation: "environment-status", + stage: "clerk-token", + phase: "start", + accountId: "account-1", + environmentId: environment.environmentId, + }); + expect(onQueryEvent).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "environment-status", + stage: "relay-request", + phase: "success", + accountId: "account-1", + environmentId: environment.environmentId, + }), + ); + }); + + it("rejects status responses for a different environment", async () => { + const mismatchedStatus = { + environmentId: EnvironmentId.make("environment-2"), + endpoint: environment.endpoint, + status: "online", + checkedAt: "2026-06-01T00:00:00.000Z", + } satisfies RelayEnvironmentStatusResponse; + const manager = createManager({ + getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( + "Relay returned status for a different environment.", + ); + }); + }); + + it("exposes relay trace IDs alongside snapshot errors", async () => { + const manager = createManager({ + getEnvironmentStatus: () => + Effect.fail( + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "get relay environment status", + cause: new Error("Relay request failed."), + traceId: "trace-status", + }), + ), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom))).toMatchObject({ + error: "Could not get relay environment status.", + errorTraceId: "trace-status", + }); + }); + }); +}); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts new file mode 100644 index 00000000000..ec6a0710dd1 --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -0,0 +1,450 @@ +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, +} from "@t3tools/contracts/relay"; +import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { findErrorTraceId } from "../errors/errorTrace.ts"; +import * as ManagedRelay from "./managedRelay.ts"; + +const DEFAULT_STALE_TIME_MS = 15_000; +const DEFAULT_IDLE_TTL_MS = 5 * 60_000; +const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; + +export interface ManagedRelaySession { + readonly accountId: string; + readonly readClerkToken: () => Effect.Effect; +} + +export interface ManagedRelaySessionInput { + readonly accountId: string; + readonly readClerkToken: () => Promise; +} + +interface ManagedRelaySessionControl { + readonly updateReadClerkToken: ( + readClerkToken: ManagedRelaySessionInput["readClerkToken"], + ) => void; +} + +export interface ManagedRelaySnapshotState { + readonly data: A | null; + readonly error: string | null; + readonly errorTraceId: string | null; + readonly isPending: boolean; +} + +export interface ManagedRelayQueryEvent { + readonly operation: "environments" | "devices" | "environment-status"; + readonly stage: "clerk-token" | "relay-request" | "validation"; + readonly phase: "start" | "success" | "failure"; + readonly accountId: string; + readonly environmentId?: string; + readonly message?: string; + readonly traceId?: string | null; +} + +export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{ + readonly message: string; +}> {} + +export const managedRelaySessionAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("managed-relay:session"), +); + +const managedRelaySessionControls = new WeakMap(); + +export function createManagedRelaySession(input: ManagedRelaySessionInput): ManagedRelaySession { + let cachedToken: { readonly token: string; readonly expiresAtMillis: number } | null = null; + let pendingToken: Promise | null = null; + let readClerkToken = input.readClerkToken; + let tokenProviderGeneration = 0; + + const readCachedClerkToken = async (nowMillis: number): Promise => { + if (cachedToken && cachedToken.expiresAtMillis > nowMillis + CLERK_TOKEN_EXPIRY_SKEW_MS) { + return cachedToken.token; + } + if (pendingToken) { + return await pendingToken; + } + + const operationGeneration = tokenProviderGeneration; + const operation = readClerkToken().then((token) => { + if (operationGeneration !== tokenProviderGeneration) { + return token; + } + if (!token) { + cachedToken = null; + return null; + } + try { + const expiresAtSeconds = decodeRelayJwt(token).exp; + cachedToken = + typeof expiresAtSeconds === "number" + ? { token, expiresAtMillis: expiresAtSeconds * 1_000 } + : null; + } catch { + cachedToken = null; + } + return token; + }); + pendingToken = operation; + try { + return await operation; + } finally { + if (pendingToken === operation) { + pendingToken = null; + } + } + }; + + const session: ManagedRelaySession = { + accountId: input.accountId, + readClerkToken: Effect.fn("clientRuntime.managedRelaySession.readClerkToken")(function* () { + const nowMillis = yield* Clock.currentTimeMillis; + return yield* Effect.tryPromise({ + try: () => readCachedClerkToken(nowMillis), + catch: (cause) => + new ManagedRelaySessionError({ + message: "Could not obtain the T3 Cloud session token.", + cause, + }), + }); + }), + }; + managedRelaySessionControls.set(session, { + updateReadClerkToken: (nextReadClerkToken) => { + readClerkToken = nextReadClerkToken; + tokenProviderGeneration += 1; + pendingToken = null; + }, + }); + return session; +} + +export function setManagedRelaySession( + registry: AtomRegistry.AtomRegistry, + input: ManagedRelaySessionInput | null, +): void { + const current = registry.get(managedRelaySessionAtom); + if (input === null) { + if (current !== null) { + registry.set(managedRelaySessionAtom, null); + } + return; + } + if (current?.accountId === input.accountId) { + const control = managedRelaySessionControls.get(current); + if (control) { + // Clerk can replace its token reader during routine same-account refreshes. + // Keep the session stable so those refreshes do not invalidate queries or reconnect leases. + control.updateReadClerkToken(input.readClerkToken); + return; + } + } + registry.set(managedRelaySessionAtom, createManagedRelaySession(input)); +} + +export function managedRelayAccountChanges( + registry: AtomRegistry.AtomRegistry, +): Stream.Stream { + return AtomRegistry.toStream(registry, managedRelaySessionAtom).pipe( + Stream.map((session) => session?.accountId ?? null), + Stream.changes, + Stream.drop(1), + ); +} + +function readSessionClerkToken( + session: ManagedRelaySession, +): Effect.Effect { + return session.readClerkToken().pipe( + Effect.flatMap((token) => + token + ? Effect.succeed(token) + : Effect.fail( + new ManagedRelaySessionError({ + message: "The T3 Cloud session token is unavailable.", + }), + ), + ), + ); +} + +export const waitForManagedRelayClerkToken = Effect.fn( + "clientRuntime.managedRelaySession.waitForClerkToken", +)(function* (registry: AtomRegistry.AtomRegistry) { + return yield* Effect.callback((resume) => { + let unsubscribe: (() => void) | undefined; + let completed = false; + const readCurrentSession = () => { + if (completed) { + return true; + } + const session = registry.get(managedRelaySessionAtom); + if (!session) { + return false; + } + completed = true; + unsubscribe?.(); + resume(readSessionClerkToken(session)); + return true; + }; + + if (readCurrentSession()) { + return; + } + + unsubscribe = registry.subscribe(managedRelaySessionAtom, readCurrentSession); + readCurrentSession(); + return Effect.sync(() => unsubscribe?.()); + }); +}); + +function requireClerkToken( + get: Atom.AtomContext, + accountId: string, +): Effect.Effect { + const session = get(managedRelaySessionAtom); + if (!session || session.accountId !== accountId) { + return Effect.fail( + new ManagedRelaySessionError({ + message: "Sign in to T3 Cloud before loading relay data.", + }), + ); + } + return readSessionClerkToken(session); +} + +function statusKey(input: { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; +}): string { + return JSON.stringify(input); +} + +function parseStatusKey(key: string): { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; +} { + return JSON.parse(key) as { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; + }; +} + +function endpointMatches( + left: RelayClientEnvironmentRecord["endpoint"], + right: RelayClientEnvironmentRecord["endpoint"], +): boolean { + return ( + left.httpBaseUrl === right.httpBaseUrl && + left.wsBaseUrl === right.wsBaseUrl && + left.providerKind === right.providerKind + ); +} + +function validateEnvironmentStatus( + environment: RelayClientEnvironmentRecord, + status: RelayEnvironmentStatusResponse, +): Effect.Effect { + if (status.environmentId !== environment.environmentId) { + return Effect.fail( + new ManagedRelaySnapshotError({ + message: "Relay returned status for a different environment.", + }), + ); + } + if (!endpointMatches(status.endpoint, environment.endpoint)) { + return Effect.fail( + new ManagedRelaySnapshotError({ + message: "Relay returned status for a different endpoint.", + }), + ); + } + if (status.descriptor && status.descriptor.environmentId !== environment.environmentId) { + return Effect.fail( + new ManagedRelaySnapshotError({ + message: "Relay returned status descriptor for a different environment.", + }), + ); + } + return Effect.succeed(status); +} + +export function readManagedRelaySnapshotState( + result: AsyncResult.AsyncResult, +): ManagedRelaySnapshotState { + let error: string | null = null; + let errorTraceId: string | null = null; + if (result._tag === "Failure") { + const cause = Cause.squash(result.cause); + error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; + errorTraceId = findErrorTraceId(cause); + } + return { + data: Option.getOrNull(AsyncResult.value(result)), + error, + errorTraceId, + isPending: result.waiting, + }; +} + +export function createManagedRelayQueryManager( + runtime: Atom.AtomRuntime, + options?: { + readonly staleTimeMs?: number; + readonly idleTtlMs?: number; + readonly onQueryEvent?: (event: ManagedRelayQueryEvent) => void; + }, +) { + const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; + const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; + const observe = ( + input: Omit, + effect: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + options?.onQueryEvent?.({ ...input, phase: "start" }); + return yield* effect.pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (exit._tag === "Success") { + options?.onQueryEvent?.({ ...input, phase: "success" }); + return; + } + const error = Cause.squash(exit.cause); + options?.onQueryEvent?.({ + ...input, + phase: "failure", + message: error instanceof Error ? error.message : String(error), + traceId: findErrorTraceId(error), + }); + }), + ), + ); + }); + + const environmentsAtom = Atom.family((accountId: string) => + runtime + .atom((get) => + Effect.gen(function* () { + const base = { operation: "environments" as const, accountId }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); + const relay = yield* ManagedRelay.ManagedRelayClient; + return yield* observe( + { ...base, stage: "relay-request" }, + relay.listEnvironments({ clerkToken }), + ); + }), + ) + .pipe( + Atom.swr({ staleTime, revalidateOnMount: true }), + Atom.setIdleTTL(idleTtl), + Atom.withLabel(`managed-relay:environments:${accountId}`), + ), + ); + + const devicesAtom = Atom.family((accountId: string) => + runtime + .atom((get) => + Effect.gen(function* () { + const base = { operation: "devices" as const, accountId }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); + const relay = yield* ManagedRelay.ManagedRelayClient; + return yield* observe( + { ...base, stage: "relay-request" }, + relay.listDevices({ clerkToken }), + ); + }), + ) + .pipe( + Atom.swr({ staleTime, revalidateOnMount: true }), + Atom.setIdleTTL(idleTtl), + Atom.withLabel(`managed-relay:devices:${accountId}`), + ), + ); + + const environmentStatusAtom = Atom.family((key: string) => { + const { accountId, environment } = parseStatusKey(key); + return runtime + .atom((get) => + Effect.gen(function* () { + const base = { + operation: "environment-status" as const, + accountId, + environmentId: environment.environmentId, + }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); + const relay = yield* ManagedRelay.ManagedRelayClient; + const status = yield* observe( + { ...base, stage: "relay-request" }, + relay.getEnvironmentStatus({ + clerkToken, + scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + environmentId: environment.environmentId, + }), + ); + return yield* observe( + { ...base, stage: "validation" }, + validateEnvironmentStatus(environment, status), + ); + }), + ) + .pipe( + Atom.swr({ staleTime, revalidateOnMount: true }), + Atom.setIdleTTL(idleTtl), + Atom.withLabel(`managed-relay:environment-status:${key}`), + ); + }); + + return { + environmentsAtom, + devicesAtom, + environmentStatusAtom: (input: { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; + }) => environmentStatusAtom(statusKey(input)), + refreshEnvironments(registry: AtomRegistry.AtomRegistry, accountId: string): void { + registry.refresh(environmentsAtom(accountId)); + }, + refreshDevices(registry: AtomRegistry.AtomRegistry, accountId: string): void { + registry.refresh(devicesAtom(accountId)); + }, + refreshEnvironmentStatus( + registry: AtomRegistry.AtomRegistry, + input: { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; + }, + ): void { + registry.refresh(environmentStatusAtom(statusKey(input))); + }, + }; +} diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts deleted file mode 100644 index ea6c8f59e5c..00000000000 --- a/packages/client-runtime/src/remote.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { - AuthAccessTokenType, - type AuthClientPresentationMetadata, - AuthEnvironmentBootstrapTokenType, - AuthTokenExchangeGrantType, - type AuthEnvironmentScope, - EnvironmentHttpApi, - EnvironmentHttpCommonError, -} from "@t3tools/contracts"; -import type { - EnvironmentAuthInvalidError, - EnvironmentInternalError, - EnvironmentOperationForbiddenError, - EnvironmentRequestInvalidError, - EnvironmentScopeRequiredError, -} from "@t3tools/contracts"; -import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; -import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; -import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; -import { normalizeBasePath } from "@t3tools/shared/basePath"; - -const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; -const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); - -export const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { - const url = new URL(httpBaseUrl); - url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}${pathname}`; - url.search = ""; - url.hash = ""; - return url.toString(); -}; - -const remoteApiBaseUrl = (httpBaseUrl: string): string => { - const url = new URL(httpBaseUrl); - const basePath = Effect.runSync(normalizeBasePath(url.pathname)); - url.pathname = `${basePath}/`; - url.search = ""; - url.hash = ""; - return url.toString(); -}; - -const clientMetadataTokenExchangeFields = ( - clientMetadata: AuthClientPresentationMetadata | undefined, -) => ({ - ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), - ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), - ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), -}); - -export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( - "RemoteEnvironmentAuthFetchError", -)<{ - readonly message: string; - readonly cause: unknown; -}> {} - -export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( - "RemoteEnvironmentAuthInvalidJsonError", -)<{ - readonly message: string; - readonly cause: unknown; -}> {} - -export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( - "RemoteEnvironmentAuthUndeclaredStatusError", -)<{ - readonly message: string; - readonly status: number; - readonly requestUrl: string; -}> { - constructor(requestUrl: string, status: number) { - super({ - message: `Remote auth endpoint ${requestUrl} returned undeclared status ${status}.`, - requestUrl, - status, - }); - } -} - -export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( - "RemoteEnvironmentAuthTimeoutError", -)<{ - readonly message: string; - readonly requestUrl: string; - readonly timeoutMs: number; -}> { - constructor(requestUrl: string, timeoutMs: number) { - super({ - message: `Remote auth endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, - requestUrl, - timeoutMs, - }); - } -} - -export type RemoteEnvironmentAuthError = - | EnvironmentRequestInvalidError - | EnvironmentAuthInvalidError - | EnvironmentScopeRequiredError - | EnvironmentOperationForbiddenError - | EnvironmentInternalError - | RemoteEnvironmentAuthFetchError - | RemoteEnvironmentAuthInvalidJsonError - | RemoteEnvironmentAuthUndeclaredStatusError - | RemoteEnvironmentAuthTimeoutError; - -export const remoteHttpClientLayer = ( - fetchFn: typeof globalThis.fetch, -): Layer.Layer => - Layer.merge( - FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), - httpHeaderRedactionLayer, - ); - -const failRemoteRequest = ( - requestUrl: string, - cause: unknown, -): Effect.Effect => { - if (cause instanceof RemoteEnvironmentAuthTimeoutError) { - return Effect.fail(cause); - } - if (isEnvironmentHttpCommonError(cause)) { - return Effect.fail(cause); - } - if (Schema.isSchemaError(cause)) { - return Effect.fail( - new RemoteEnvironmentAuthInvalidJsonError({ - message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, - cause, - }), - ); - } - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - const response = cause.response; - if (response.status < 200 || response.status >= 300) { - return Effect.fail( - new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), - ); - } - return Effect.fail( - new RemoteEnvironmentAuthInvalidJsonError({ - message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, - cause, - }), - ); - } - return Effect.fail( - new RemoteEnvironmentAuthFetchError({ - message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`, - cause, - }), - ); -}; - -const executeRemoteRequest = ( - requestUrl: string, - timeoutMs: number, - request: Effect.Effect, -): Effect.Effect => - request.pipe( - Effect.timeoutOption(Duration.millis(timeoutMs)), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), - onSome: Effect.succeed, - }), - ), - Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), - ); - -export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => - HttpApiClient.make(EnvironmentHttpApi, { - baseUrl: remoteApiBaseUrl(httpBaseUrl), - }); - -export const exchangeRemoteDpopAccessToken = Effect.fn( - "clientRuntime.remote.exchangeRemoteDpopAccessToken", -)(function* (input: { - readonly httpBaseUrl: string; - readonly credential: string; - readonly scopes?: ReadonlyArray; - readonly clientMetadata?: AuthClientPresentationMetadata; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - const response = yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ - headers: { dpop: input.dpopProof }, - payload: { - grant_type: AuthTokenExchangeGrantType, - subject_token: input.credential, - subject_token_type: AuthEnvironmentBootstrapTokenType, - requested_token_type: AuthAccessTokenType, - ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), - ...clientMetadataTokenExchangeFields(input.clientMetadata), - }, - }), - ); - return response; -}); - -export const bootstrapRemoteBearerSession = Effect.fn( - "clientRuntime.remote.bootstrapRemoteBearerSession", -)(function* (input: { - readonly httpBaseUrl: string; - readonly credential: string; - readonly scopes?: ReadonlyArray; - readonly clientMetadata?: AuthClientPresentationMetadata; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ - headers: {}, - payload: { - grant_type: AuthTokenExchangeGrantType, - subject_token: input.credential, - subject_token_type: AuthEnvironmentBootstrapTokenType, - requested_token_type: AuthAccessTokenType, - ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), - ...clientMetadataTokenExchangeFields(input.clientMetadata), - }, - }), - ); -}); - -export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")( - function* (input: { - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; - }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ - headers: { - authorization: `Bearer ${input.bearerToken}`, - }, - }), - ); - }, -); - -export const fetchRemoteDpopSessionState = Effect.fn( - "clientRuntime.remote.fetchRemoteDpopSessionState", -)(function* (input: { - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ - headers: { - authorization: `DPoP ${input.accessToken}`, - dpop: input.dpopProof, - }, - }), - ); -}); - -export const fetchRemoteEnvironmentDescriptor = Effect.fn( - "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", -)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.metadata.descriptor(), - ); -}); - -export const issueRemoteWebSocketTicket = Effect.fn( - "clientRuntime.remote.issueRemoteWebSocketTicket", -)(function* (input: { - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ - headers: { - authorization: `Bearer ${input.bearerToken}`, - }, - }), - ); -}); - -export const issueRemoteDpopWebSocketTicket = Effect.fn( - "clientRuntime.remote.issueRemoteDpopWebSocketTicket", -)(function* (input: { - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ - headers: { - authorization: `DPoP ${input.accessToken}`, - dpop: input.dpopProof, - }, - }), - ); -}); - -export const resolveRemoteWebSocketConnectionUrl = Effect.fn( - "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", -)(function* (input: { - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; -}) { - const issued = yield* issueRemoteWebSocketTicket({ - httpBaseUrl: input.httpBaseUrl, - bearerToken: input.bearerToken, - ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), - }); - - const url = new URL(input.wsBaseUrl); - const basePath = yield* normalizeBasePath(new URL(input.httpBaseUrl).pathname); - url.pathname = basePath || "/"; - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -}); - -export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( - "clientRuntime.remote.resolveRemoteDpopWebSocketConnectionUrl", -)(function* (input: { - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const issued = yield* issueRemoteDpopWebSocketTicket({ - httpBaseUrl: input.httpBaseUrl, - accessToken: input.accessToken, - dpopProof: input.dpopProof, - ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), - }); - const url = new URL(input.wsBaseUrl); - const basePath = yield* normalizeBasePath(new URL(input.httpBaseUrl).pathname); - url.pathname = basePath || "/"; - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -}); diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts new file mode 100644 index 00000000000..507d137cacc --- /dev/null +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -0,0 +1,390 @@ +import { + EnvironmentId, + type RelayClientInstallProgressEvent, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import { RpcClientError } from "effect/unstable/rpc"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcSession from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const INSTALL_CHECKING: RelayClientInstallProgressEvent = { + type: "progress", + stage: "checking", +}; +const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { + type: "progress", + stage: "downloading", +}; + +function session(client: WsRpcProtocolClient): RpcSession.RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { + const state = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>( + Option.none(), + ); + const prepared = yield* SubscriptionRef.make>(Option.none()); + const retryCount = yield* Ref.make(0); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state, + session: activeSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Ref.update(retryCount, (count) => count + 1), + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + return { + activeSession, + retryCount, + supervisor, + }; +}); + +describe("environment RPC", () => { + it.effect("observes unary requests until they complete", () => + Effect.gen(function* () { + const observations: string[] = []; + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Effect.succeed({ status: "available", version: "2026.6.0" }), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + const result = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: ({ environmentId, method }) => + Effect.sync(() => { + observations.push(`start:${environmentId}:${method}`); + return Effect.sync(() => { + observations.push(`finish:${environmentId}:${method}`); + }); + }), + }), + ), + ); + + expect(result).toEqual({ status: "available", version: "2026.6.0" }); + expect(observations).toEqual([ + `start:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, + `finish:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, + ]); + }), + ); + + it.effect("binds finite streaming commands to one active session", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded(); + const secondEvents = yield* Queue.unbounded(); + const firstClient = { + [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromQueue(firstEvents), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromQueue(secondEvents), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + const resultFiber = yield* runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( + Stream.take(2), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* Queue.offer(firstEvents, INSTALL_CHECKING); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + yield* Queue.offer(secondEvents, INSTALL_DOWNLOADING); + yield* Queue.offer(firstEvents, INSTALL_DOWNLOADING); + + expect(yield* Fiber.join(resultFiber)).toEqual([INSTALL_CHECKING, INSTALL_DOWNLOADING]); + }), + ); + + it.effect("switches durable subscriptions when the supervisor replaces the session", () => + Effect.gen(function* () { + const subscriptions: string[] = []; + const firstClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("first"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("second"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + const awaitSubscriptions = Effect.fn("TestEnvironmentRpc.awaitSubscriptions")(function* ( + count: number, + ) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (subscriptions.length >= count) { + return; + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error(`Expected ${count} durable subscriptions.`)); + }); + + const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + yield* awaitSubscriptions(1); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + yield* awaitSubscriptions(2); + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["first", "second"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("keeps durable subscriptions alive across a transport failure and new session", () => + Effect.gen(function* () { + const subscriptions: string[] = []; + const firstClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("first"); + return Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "socket closed", + cause: new Error("socket closed"), + }), + }), + ); + }, + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("second"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + for (let attempt = 0; attempt < 100 && subscriptions.length < 1; attempt += 1) { + yield* Effect.yieldNow; + } + yield* SubscriptionRef.set(activeSession, Option.none()); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + + for (let attempt = 0; attempt < 100 && subscriptions.length < 2; attempt += 1) { + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["first", "second"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("surfaces domain subscription failures without reconnecting", () => + Effect.gen(function* () { + const domainError = new Error("terminal subscription rejected"); + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => Stream.fail(domainError), + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const error = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.flip, + ); + + expect(error).toBe(domainError); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("keeps handled domain failures dormant until a replacement session arrives", () => + Effect.gen(function* () { + const domainError = new Error("terminal subscription rejected"); + const subscriptions: string[] = []; + const observedFailures: Error[] = []; + const firstClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("first"); + return Stream.fail(domainError); + }, + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("second"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: (cause) => + Effect.sync(() => { + observedFailures.push(Cause.squash(cause) as Error); + }), + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + for (let attempt = 0; attempt < 100 && observedFailures.length < 1; attempt += 1) { + yield* Effect.yieldNow; + } + + expect(subscriptions).toEqual(["first"]); + expect(observedFailures).toEqual([domainError]); + + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + for (let attempt = 0; attempt < 100 && subscriptions.length < 2; attempt += 1) { + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["first", "second"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("retries handled domain failures within the same session when configured", () => + Effect.gen(function* () { + const domainError = new Error("thread not found yet"); + const subscriptionCount = yield* Ref.make(0); + const expectedFailureCount = yield* Ref.make(0); + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => + Stream.unwrap( + Ref.getAndUpdate(subscriptionCount, (count) => count + 1).pipe( + Effect.map((count) => (count === 0 ? Stream.fail(domainError) : Stream.never)), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => Ref.update(expectedFailureCount, (count) => count + 1), + retryExpectedFailureAfter: "100 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(expectedFailureCount)) >= 1) { + break; + } + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(subscriptionCount)).toBe(1); + expect(yield* Ref.get(expectedFailureCount)).toBe(1); + + yield* TestClock.adjust("100 millis"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(subscriptionCount)) >= 2) { + break; + } + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(subscriptionFiber); + + expect(yield* Ref.get(subscriptionCount)).toBe(2); + expect(yield* Ref.get(expectedFailureCount)).toBe(1); + }), + ); + + it.effect("does not classify subscription defects as expected failures", () => + Effect.gen(function* () { + const defect = new Error("subscription invariant failed"); + let expectedFailureCount = 0; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => Stream.die(defect), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const exit = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => + Effect.sync(() => { + expectedFailureCount += 1; + }), + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + } + expect(expectedFailureCount).toBe(0); + }), + ); +}); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts new file mode 100644 index 00000000000..92892431e45 --- /dev/null +++ b/packages/client-runtime/src/rpc/client.ts @@ -0,0 +1,242 @@ +import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { RpcClientError } from "effect/unstable/rpc"; + +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; + +export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass()( + "EnvironmentRpcUnavailableError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export interface EnvironmentRpcRequestObservation { + readonly environmentId: string; + readonly method: string; +} + +export class EnvironmentRpcRequestObserver extends Context.Reference<{ + readonly observe: ( + request: EnvironmentRpcRequestObservation, + ) => Effect.Effect>; +}>("@t3tools/client-runtime/rpc/EnvironmentRpcRequestObserver", { + defaultValue: () => ({ + observe: () => Effect.succeed(Effect.void), + }), +}) {} + +export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; +type RpcMethod = WsRpcProtocolClient[TTag]; + +export type EnvironmentSubscriptionRpcTag = + | typeof ORCHESTRATION_WS_METHODS.subscribeShell + | typeof ORCHESTRATION_WS_METHODS.subscribeThread + | typeof WS_METHODS.subscribeAuthAccess + | typeof WS_METHODS.subscribeServerConfig + | typeof WS_METHODS.subscribeServerLifecycle + | typeof WS_METHODS.subscribeTerminalEvents + | typeof WS_METHODS.subscribeTerminalMetadata + | typeof WS_METHODS.subscribePreviewEvents + | typeof WS_METHODS.subscribeDiscoveredLocalServers + | typeof WS_METHODS.previewAutomationConnect + | typeof WS_METHODS.subscribeVcsStatus + | typeof WS_METHODS.terminalAttach; + +export type EnvironmentStreamCommandRpcTag = + | typeof WS_METHODS.cloudInstallRelayClient + | typeof WS_METHODS.gitRunStackedAction; + +export type EnvironmentStreamRpcTag = + | EnvironmentSubscriptionRpcTag + | EnvironmentStreamCommandRpcTag; + +export type EnvironmentUnaryRpcTag = Exclude; +const isRpcClientError = Schema.is(RpcClientError.RpcClientError); + +export type EnvironmentRpcInput = Parameters>[0]; + +export type EnvironmentRpcSuccess = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? A + : never; + +export type EnvironmentRpcFailure = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? E + : never; + +export type EnvironmentRpcStreamValue = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? A + : never; + +export type EnvironmentRpcStreamFailure = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? E + : never; + +const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { + const supervisor = yield* EnvironmentSupervisor; + return yield* SubscriptionRef.get(supervisor.session).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: `${supervisor.target.label} is not connected.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); +}); + +export const request = Effect.fn("EnvironmentRpc.request")(function* < + TTag extends EnvironmentUnaryRpcTag, +>(tag: TTag, input: EnvironmentRpcInput) { + const supervisor = yield* EnvironmentSupervisor; + yield* Effect.annotateCurrentSpan({ + "environment.id": supervisor.target.environmentId, + "rpc.method": tag, + }); + const session = yield* currentSession(); + const observer = yield* EnvironmentRpcRequestObserver; + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Effect.Effect, EnvironmentRpcFailure>; + const completeObservation = yield* observer.observe({ + environmentId: supervisor.target.environmentId, + method: tag, + }); + return yield* method(input).pipe(Effect.ensuring(completeObservation)); +}); + +export function runStream( + tag: TTag, + input: EnvironmentRpcInput, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure | EnvironmentRpcUnavailableError, + EnvironmentSupervisor +> { + return Stream.unwrap( + currentSession().pipe( + Effect.map((session) => { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream, EnvironmentRpcStreamFailure>; + return method(input); + }), + ), + ).pipe( + Stream.withSpan("EnvironmentRpc.runStream", { + attributes: { "rpc.method": tag }, + }), + ); +} + +export function subscribe( + tag: TTag, + input: EnvironmentRpcInput, + options?: { + readonly onExpectedFailure?: ( + cause: Cause.Cause>, + ) => Effect.Effect; + readonly retryExpectedFailureAfter?: Duration.Input; + }, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: (session) => { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure + >; + const subscribeToSession = (): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure + > => + Stream.suspend(() => + method(input).pipe( + Stream.catchCause((cause) => { + const hasOnlyExpectedFailures = + cause.reasons.length > 0 && + cause.reasons.every((reason) => reason._tag === "Fail"); + const isTransportFailure = + hasOnlyExpectedFailures && + cause.reasons.every( + (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (isTransportFailure) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + } + if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { + const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( + Stream.drain, + ); + if (options.retryExpectedFailureAfter === undefined) { + return handled; + } + return handled.pipe( + Stream.concat( + Stream.fromEffect( + Effect.sleep(options.retryExpectedFailureAfter), + ).pipe(Stream.drain), + ), + Stream.concat(subscribeToSession()), + ); + } + return Stream.failCause(cause); + }), + ), + ); + return subscribeToSession(); + }, + }), + ), + ), + ), + ), + ).pipe( + Stream.withSpan("EnvironmentRpc.subscribe", { + attributes: { "rpc.method": tag }, + }), + ); +} + +export const config = Effect.gen(function* () { + const session = yield* currentSession(); + return yield* session.initialConfig; +}).pipe(Effect.withSpan("EnvironmentRpc.config")); diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts new file mode 100644 index 00000000000..11bfa794fa7 --- /dev/null +++ b/packages/client-runtime/src/rpc/http.ts @@ -0,0 +1,154 @@ +import { + EnvironmentHttpApi, + EnvironmentHttpCommonError, + type EnvironmentAuthInvalidError, + type EnvironmentInternalError, + type EnvironmentOperationForbiddenError, + type EnvironmentRequestInvalidError, + type EnvironmentScopeRequiredError, +} from "@t3tools/contracts"; +import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); + +export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( + "RemoteEnvironmentAuthFetchError", +)<{ + readonly message: string; + readonly cause: unknown; +}> {} + +export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( + "RemoteEnvironmentAuthInvalidJsonError", +)<{ + readonly message: string; + readonly cause: unknown; +}> {} + +export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( + "RemoteEnvironmentAuthUndeclaredStatusError", +)<{ + readonly message: string; + readonly status: number; + readonly requestUrl: string; +}> { + constructor(requestUrl: string, status: number) { + super({ + message: `Remote environment endpoint ${requestUrl} returned undeclared status ${status}.`, + requestUrl, + status, + }); + } +} + +export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( + "RemoteEnvironmentAuthTimeoutError", +)<{ + readonly message: string; + readonly requestUrl: string; + readonly timeoutMs: number; +}> { + constructor(requestUrl: string, timeoutMs: number) { + super({ + message: `Remote environment endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, + requestUrl, + timeoutMs, + }); + } +} + +export type RemoteEnvironmentRequestError = + | EnvironmentRequestInvalidError + | EnvironmentAuthInvalidError + | EnvironmentScopeRequiredError + | EnvironmentOperationForbiddenError + | EnvironmentInternalError + | RemoteEnvironmentAuthFetchError + | RemoteEnvironmentAuthInvalidJsonError + | RemoteEnvironmentAuthUndeclaredStatusError + | RemoteEnvironmentAuthTimeoutError; + +export const remoteHttpClientLayer = ( + fetchFn: typeof globalThis.fetch, +): Layer.Layer => + Layer.merge( + FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), + httpHeaderRedactionLayer, + ); + +const remoteApiBaseUrl = (httpBaseUrl: string): string => { + const url = new URL(httpBaseUrl); + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); +}; + +export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => + HttpApiClient.make(EnvironmentHttpApi, { + baseUrl: remoteApiBaseUrl(httpBaseUrl), + }); + +const failRemoteRequest = ( + requestUrl: string, + cause: unknown, +): Effect.Effect => { + if (cause instanceof RemoteEnvironmentAuthTimeoutError) { + return Effect.fail(cause); + } + if (isEnvironmentHttpCommonError(cause)) { + return Effect.fail(cause); + } + if (Schema.isSchemaError(cause)) { + return Effect.fail( + new RemoteEnvironmentAuthInvalidJsonError({ + message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`, + cause, + }), + ); + } + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + const response = cause.response; + if (response.status < 200 || response.status >= 300) { + return Effect.fail( + new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), + ); + } + return Effect.fail( + new RemoteEnvironmentAuthInvalidJsonError({ + message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`, + cause, + }), + ); + } + return Effect.fail( + new RemoteEnvironmentAuthFetchError({ + message: `Failed to fetch remote environment endpoint ${requestUrl} (${String(cause)}).`, + cause, + }), + ); +}; + +export const executeEnvironmentHttpRequest = ( + requestUrl: string, + timeoutMs: number, + request: Effect.Effect, +): Effect.Effect => + request.pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), + onSome: Effect.succeed, + }), + ), + Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), + ); diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts new file mode 100644 index 00000000000..76608388f0a --- /dev/null +++ b/packages/client-runtime/src/rpc/index.ts @@ -0,0 +1,4 @@ +export * from "./client.ts"; +export * from "./http.ts"; +export * from "./protocol.ts"; +export { type RpcSession, RpcSessionFactory } from "./session.ts"; diff --git a/packages/client-runtime/src/rpc/protocol.ts b/packages/client-runtime/src/rpc/protocol.ts new file mode 100644 index 00000000000..b8447f0d7af --- /dev/null +++ b/packages/client-runtime/src/rpc/protocol.ts @@ -0,0 +1,8 @@ +import { WsRpcGroup } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { RpcClient } from "effect/unstable/rpc"; + +export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); +type RpcClientFactory = typeof makeWsRpcProtocolClient; +export type WsRpcProtocolClient = + RpcClientFactory extends Effect.Effect ? Client : never; diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts new file mode 100644 index 00000000000..7820c93a935 --- /dev/null +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -0,0 +1,276 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ServerConfig, + type ServerConfig as ServerConfigType, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { + ConnectionTransientError, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as RpcSession from "./session.ts"; + +type SocketEventType = "open" | "message" | "close" | "error"; +type SocketEvent = { + readonly code?: number; + readonly data?: unknown; + readonly reason?: string; + readonly type: SocketEventType; +}; +type SocketListener = (event: SocketEvent) => void; + +class TestWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readyState = TestWebSocket.CONNECTING; + readonly sent: string[] = []; + readonly url: string; + private readonly listeners = new Map>(); + + constructor(url: string) { + this.url = url; + } + + addEventListener(type: SocketEventType, listener: SocketListener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: SocketEventType, listener: SocketListener) { + this.listeners.get(type)?.delete(listener); + } + + send(data: string) { + this.sent.push(data); + } + + close(code = 1000, reason = "") { + if (this.readyState === TestWebSocket.CLOSED) { + return; + } + this.readyState = TestWebSocket.CLOSED; + this.emit("close", { code, reason, type: "close" }); + } + + open() { + this.readyState = TestWebSocket.OPEN; + this.emit("open", { type: "open" }); + } + + serverMessage(data: string) { + this.emit("message", { data, type: "message" }); + } + + private emit(type: SocketEventType, event: SocketEvent) { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=test", + httpAuthorization: null, + target: TARGET, +}; + +const SERVER_CONFIG: ServerConfigType = { + environment: { + environmentId: TARGET.environmentId, + label: TARGET.label, + platform: { + os: "darwin", + arch: "arm64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token"], + sessionCookieName: "t3_session", + }, + cwd: "/tmp/workspace", + keybindingsConfigPath: "/tmp/workspace/keybindings.json", + keybindings: [], + issues: [], + providers: [], + availableEditors: [], + observability: { + logsDirectoryPath: "/tmp/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, + settings: DEFAULT_SERVER_SETTINGS, +}; + +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + payload: Schema.Unknown, + tag: Schema.String, +}); +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); +const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +const encodeServerConfig = Schema.encodeSync(ServerConfig); + +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { + const sockets: TestWebSocket[] = []; + const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { + const socket = new TestWebSocket(url); + sockets.push(socket); + return socket as unknown as globalThis.WebSocket; + }); + const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); + return { factory, sockets }; +}); + +const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( + sockets: ReadonlyArray, +) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const socket = sockets[0]; + if (socket) { + return socket; + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error("Expected the RPC protocol to create a websocket.")); +}); + +const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( + socket: TestWebSocket, +) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const request = socket.sent[0]; + if (request) { + return decodeRpcRequest(decodeJson(request)); + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error("Expected the RPC protocol to send a request.")); +}); + +const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( + socket: TestWebSocket, +) { + const request = yield* awaitRequest(socket); + expect(request).toMatchObject({ + _tag: "Request", + tag: WS_METHODS.serverGetConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Success", + value: encodeServerConfig(SERVER_CONFIG), + }, + }), + ); +}); + +describe("RpcSessionFactory", () => { + it.effect("owns one scoped websocket attempt and exposes readiness and closure", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + + expect(socket.url).toBe(PREPARED.socketUrl); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const config = yield* session.initialConfig; + expect(config).toEqual(SERVER_CONFIG); + expect(socket.sent).toHaveLength(1); + + socket.close(1012, "service restart"); + const error = yield* Effect.flip(session.closed); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ + reason: "transport", + message: "Test environment disconnected.", + }); + yield* Effect.yieldNow; + expect(sockets).toHaveLength(1); + }), + ); + + it.effect("closes the websocket when the session scope is released", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + }), + ); + + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }), + ); + + it.effect("fails readiness when the websocket never opens", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + + const error = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); + yield* awaitSocket(sockets); + + yield* TestClock.adjust("15 seconds"); + return yield* Fiber.join(readyFiber); + }), + ); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ + reason: "transport", + message: "Test environment could not establish a WebSocket connection.", + }); + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts new file mode 100644 index 00000000000..f9594c1b7ca --- /dev/null +++ b/packages/client-runtime/src/rpc/session.ts @@ -0,0 +1,144 @@ +import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; +import type { + ConnectionAttemptError, + ConnectionTransientError, + PreparedConnection, +} from "../connection/model.ts"; +import { + ConnectionBlockedError, + ConnectionTransientError as ConnectionTransientErrorClass, +} from "../connection/model.ts"; + +const SOCKET_OPEN_TIMEOUT = "15 seconds"; + +export interface RpcSession { + readonly client: WsRpcProtocolClient; + readonly initialConfig: Effect.Effect; + readonly ready: Effect.Effect; + readonly probe: Effect.Effect; + readonly closed: Effect.Effect; +} + +export class RpcSessionFactory extends Context.Service< + RpcSessionFactory, + { + readonly connect: ( + connection: PreparedConnection, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/rpc/session/RpcSessionFactory") {} + +type InitialConfigError = Effect.Error< + ReturnType +>; + +function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptError { + switch (error._tag) { + case "EnvironmentAuthorizationError": + return new ConnectionBlockedError({ + reason: "permission", + detail: error.message, + }); + case "KeybindingsConfigParseError": + case "ServerSettingsError": + return new ConnectionTransientErrorClass({ + reason: "remote-unavailable", + detail: error.message, + }); + case "RpcClientError": + return new ConnectionTransientErrorClass({ + reason: "transport", + detail: error.message, + }); + } +} + +export const make = Effect.gen(function* () { + const webSocketConstructor = yield* Socket.WebSocketConstructor; + + const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": connection.environmentId, + }); + + const connected = yield* Deferred.make(); + const disconnected = yield* Deferred.make(); + const hooks = RpcClient.ConnectionHooks.of({ + onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), + onDisconnect: Deferred.isDone(connected).pipe( + Effect.flatMap((wasConnected) => + Deferred.fail( + disconnected, + new ConnectionTransientErrorClass({ + reason: "transport", + detail: wasConnected + ? `${connection.label} disconnected.` + : `${connection.label} could not establish a WebSocket connection.`, + }), + ), + ), + Effect.asVoid, + ), + }); + const socketLayer = Socket.layerWebSocket(connection.socketUrl, { + openTimeout: SOCKET_OPEN_TIMEOUT, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); + const protocolLayer = Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + retryPolicy: Schedule.recurs(0), + }), + ).pipe( + Layer.provide( + Layer.mergeAll( + socketLayer, + RpcSerialization.layerJson, + Layer.succeed(RpcClient.ConnectionHooks, hooks), + ), + ), + ); + const protocolContext = yield* Layer.build(protocolLayer).pipe( + Effect.withSpan("environment.websocket.connect"), + ); + const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfig = yield* Effect.cached( + client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(mapInitialConfigError), + Effect.withSpan("environment.initialSync"), + ), + ); + const probe = client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(mapInitialConfigError), + Effect.asVoid, + Effect.withSpan("clientRuntime.connection.rpcSession.probe"), + ); + + return { + client, + initialConfig, + ready: Deferred.await(connected).pipe( + Effect.andThen(initialConfig), + Effect.asVoid, + Effect.raceFirst(Deferred.await(disconnected)), + ), + probe, + closed: Deferred.await(disconnected), + } satisfies RpcSession; + }); + + return RpcSessionFactory.of({ connect }); +}); + +export const layer = Layer.effect(RpcSessionFactory, make); diff --git a/packages/client-runtime/src/shellSnapshotState.test.ts b/packages/client-runtime/src/shellSnapshotState.test.ts deleted file mode 100644 index c9aceca3fd3..00000000000 --- a/packages/client-runtime/src/shellSnapshotState.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { - EnvironmentId, - ProjectId, - ProviderInstanceId, - ThreadId, - type OrchestrationShellSnapshot, -} from "@t3tools/contracts"; - -import { createShellSnapshotManager } from "./shellSnapshotState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const BASE_SNAPSHOT: OrchestrationShellSnapshot = { - snapshotSequence: 1, - updatedAt: "2026-04-01T00:00:00.000Z", - projects: [ - { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/repo", - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - }, - ], - threads: [ - { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - archivedAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - goal: null, - }, - ], -}; - -const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; - -describe("createShellSnapshotManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("starts pending when marked pending", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.markPending(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: true, - }); - }); - - it("stores snapshots", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: BASE_SNAPSHOT, - error: null, - isPending: false, - }); - }); - - it("applies incremental shell events", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - const existingThread = BASE_SNAPSHOT.threads[0]!; - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - manager.applyEvent(TARGET, { - kind: "thread-upserted", - sequence: 2, - thread: { - ...existingThread, - title: "Renamed thread", - }, - }); - - expect(manager.getSnapshot(TARGET).data?.threads[0]?.title).toBe("Renamed thread"); - expect(manager.getSnapshot(TARGET).data?.snapshotSequence).toBe(2); - }); - - it("invalidates per environment", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - manager.invalidate(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: false, - }); - }); -}); diff --git a/packages/client-runtime/src/shellSnapshotState.ts b/packages/client-runtime/src/shellSnapshotState.ts deleted file mode 100644 index e694d50e309..00000000000 --- a/packages/client-runtime/src/shellSnapshotState.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { - EnvironmentId, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, -} from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; - -export interface ShellSnapshotState { - readonly data: OrchestrationShellSnapshot | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface ShellSnapshotTarget { - readonly environmentId: EnvironmentId | null; -} - -export const EMPTY_SHELL_SNAPSHOT_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_SHELL_SNAPSHOT_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownShellSnapshotKeys = new Set(); - -export const shellSnapshotStateAtom = Atom.family((key: string) => { - knownShellSnapshotKeys.add(key); - return Atom.make(INITIAL_SHELL_SNAPSHOT_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`shell-snapshot:${key}`), - ); -}); - -export const EMPTY_SHELL_SNAPSHOT_ATOM = Atom.make(EMPTY_SHELL_SNAPSHOT_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("shell-snapshot:null"), -); - -export function getShellSnapshotTargetKey(target: ShellSnapshotTarget): string | null { - return target.environmentId; -} - -export interface ShellSnapshotManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; -} - -export function createShellSnapshotManager(config: ShellSnapshotManagerConfig) { - function getSnapshot(target: ShellSnapshotTarget): ShellSnapshotState { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return EMPTY_SHELL_SNAPSHOT_STATE; - } - - return config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: ShellSnapshotState): void { - config.getRegistry().set(shellSnapshotStateAtom(targetKey), nextState); - } - - function markPending(target: ShellSnapshotTarget): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: null, - isPending: true, - }); - } - - function syncSnapshot(target: ShellSnapshotTarget, snapshot: OrchestrationShellSnapshot): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - setState(targetKey, { - data: snapshot, - error: null, - isPending: false, - }); - } - - function applyEvent(target: ShellSnapshotTarget, event: OrchestrationShellStreamEvent): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - if (current.data === null) { - return; - } - - setState(targetKey, { - data: applyShellStreamEvent(current.data, event), - error: null, - isPending: false, - }); - } - - function invalidate(target?: ShellSnapshotTarget): void { - if (target) { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey !== null) { - setState(targetKey, EMPTY_SHELL_SNAPSHOT_STATE); - } - return; - } - - for (const key of knownShellSnapshotKeys) { - setState(key, EMPTY_SHELL_SNAPSHOT_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - markPending, - syncSnapshot, - applyEvent, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts b/packages/client-runtime/src/sourceControlDiscoveryState.test.ts deleted file mode 100644 index 9275ab64ee0..00000000000 --- a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { assert, beforeEach, it } from "vite-plus/test"; -import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, - createSourceControlDiscoveryManager, -} from "./sourceControlDiscoveryState.ts"; - -const EMPTY_RESULT: SourceControlDiscoveryResult = { - versionControlSystems: [], - sourceControlProviders: [], -}; - -const GITHUB_RESULT: SourceControlDiscoveryResult = { - versionControlSystems: [ - { - kind: "git", - label: "Git", - implemented: true, - status: "available", - version: Option.some("2.51.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [ - { - kind: "github", - label: "GitHub", - status: "available", - version: Option.some("2.85.0"), - installHint: "Install GitHub CLI.", - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("octo"), - host: Option.some("github.com"), - detail: Option.none(), - }, - }, - ], -}; - -function unresolvedDiscovery() { - throw new Error("Discovery resolver was not initialized."); -} - -let registry = AtomRegistry.make(); - -const noop = () => undefined; - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -it("stores refreshed discovery data in an atom snapshot", async () => { - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => EMPTY_RESULT, - }), - }); - - assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE); - - const result = await manager.refresh({ key: "primary" }); - - assert.strictEqual(result, EMPTY_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); -}); - -it("deduplicates in-flight discovery refreshes by target key", async () => { - let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; - let calls = 0; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: () => { - calls += 1; - return new Promise((resolve) => { - resolveDiscovery = resolve; - }); - }, - }), - }); - - const first = manager.refresh({ key: "primary" }); - const second = manager.refresh({ key: "primary" }); - - assert.strictEqual(first, second); - assert.strictEqual(calls, 1); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); - - resolveDiscovery(EMPTY_RESULT); - await first; - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); -}); - -it("keeps the previous snapshot when refresh fails", async () => { - let shouldFail = false; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => { - if (shouldFail) { - throw new Error("probe failed"); - } - return EMPTY_RESULT; - }, - }), - }); - - await manager.refresh({ key: "primary" }); - shouldFail = true; - - const result = await manager.refresh({ key: "primary" }); - - assert.strictEqual(result, EMPTY_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: "probe failed", - isPending: false, - }); -}); - -it("invalidates a discovery target back to the initial snapshot", async () => { - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => GITHUB_RESULT, - }), - }); - - await manager.refresh({ key: "primary" }); - manager.invalidate({ key: "primary" }); - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); -}); - -it("ignores an in-flight refresh after the target is invalidated", async () => { - let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: () => - new Promise((resolve) => { - resolveDiscovery = resolve; - }), - }), - }); - - const refresh = manager.refresh({ key: "primary" }); - manager.invalidate({ key: "primary" }); - resolveDiscovery(GITHUB_RESULT); - - const result = await refresh; - - assert.strictEqual(result, GITHUB_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); -}); - -it("watches a discovery target with ref-counted client-change subscriptions", async () => { - let listener: () => void = noop; - let subscribeCalls = 0; - let unsubscribeCalls = 0; - let discoveryCalls = 0; - const client = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return EMPTY_RESULT; - }, - }; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => client, - subscribeClientChanges: (nextListener) => { - subscribeCalls += 1; - listener = nextListener; - return () => { - unsubscribeCalls += 1; - }; - }, - }); - - const firstUnwatch = manager.watch({ key: "primary" }); - const secondUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - - assert.strictEqual(subscribeCalls, 1); - assert.strictEqual(discoveryCalls, 1); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); - - listener(); - await flushAsyncWork(); - assert.strictEqual(discoveryCalls, 1); - - firstUnwatch(); - assert.strictEqual(unsubscribeCalls, 0); - - secondUnwatch(); - assert.strictEqual(unsubscribeCalls, 1); -}); - -it("reuses fresh watched discovery results on remount", async () => { - let discoveryCalls = 0; - const client = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return EMPTY_RESULT; - }, - }; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => client, - staleTimeMs: 60_000, - }); - - const firstUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - firstUnwatch(); - - const secondUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - secondUnwatch(); - - assert.strictEqual(discoveryCalls, 1); -}); - -it("refreshes a watched discovery target when the resolved client is replaced", async () => { - let listener: () => void = noop; - let activeResult = EMPTY_RESULT; - let discoveryCalls = 0; - const firstClient = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return activeResult; - }, - }; - const secondClient = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return activeResult; - }, - }; - let activeClient = firstClient; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => activeClient, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return () => undefined; - }, - }); - - const unwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); - - activeClient = secondClient; - activeResult = GITHUB_RESULT; - listener(); - await flushAsyncWork(); - - assert.strictEqual(discoveryCalls, 2); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: GITHUB_RESULT, - error: null, - isPending: false, - }); - - unwatch(); -}); diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.ts b/packages/client-runtime/src/sourceControlDiscoveryState.ts deleted file mode 100644 index 105b2baf445..00000000000 --- a/packages/client-runtime/src/sourceControlDiscoveryState.ts +++ /dev/null @@ -1,401 +0,0 @@ -import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -/* --- Types ---------------------------------------------------------- */ - -export interface SourceControlDiscoveryState { - readonly data: SourceControlDiscoveryResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface SourceControlDiscoveryTarget { - readonly key: TKey | null; -} - -export interface SourceControlDiscoveryClient { - readonly discoverSourceControl: () => Promise; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -/* --- Constants ------------------------------------------------------ */ - -export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -/* --- Atoms ---------------------------------------------------------- */ - -const knownSourceControlDiscoveryKeys = new Set(); - -export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => { - knownSourceControlDiscoveryKeys.add(key); - return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`source-control-discovery:${key}`), - ); -}); - -export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, -).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null")); - -/* --- Helpers -------------------------------------------------------- */ - -export function getSourceControlDiscoveryTargetKey( - target: SourceControlDiscoveryTarget, -): TKey | null { - const key = target.key; - return key && key.length > 0 ? key : null; -} - -/* --- Refresh manager ------------------------------------------------ */ - -export interface SourceControlDiscoveryManagerConfig { - /** - * Get the atom registry used to read/write source-control discovery snapshots. - */ - readonly getRegistry: () => AtomRegistry.AtomRegistry; - /** - * Resolve the runtime client for a discovery target key. - * - * Web currently uses a single `"primary"` target, but keeping this keyed - * lets mobile or future multi-environment clients provide separate discovery - * clients without changing the state primitive. - */ - readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null; - /** - * Optional: subscribe to environment/client availability changes. - * - * When provided, `watch` refreshes as clients appear or are replaced - * instead of relying on React hooks to manually kick discovery. - */ - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -} - -const NOOP: () => void = () => undefined; -const DEFAULT_STALE_TIME_MS = 30_000; -const DEFAULT_IDLE_TTL_MS = 5 * 60_000; - -export function createSourceControlDiscoveryManager( - config: SourceControlDiscoveryManagerConfig, -) { - const refreshInFlight = new Map< - string, - { - readonly client: SourceControlDiscoveryClient; - readonly promise: Promise; - } - >(); - const refreshVersions = new Map(); - const watched = new Map(); - const refreshTargets = new Map>(); - const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; - const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? refresh(target) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: staleTimeMs, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtlMs), - Atom.withLabel(`source-control-discovery:watched-refresh:${targetKey}`), - ), - ); - - function getRefreshVersion(targetKey: string): number { - return refreshVersions.get(targetKey) ?? 0; - } - - function bumpRefreshVersion(targetKey: string): void { - refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); - } - - /* -- Atom helpers -------------------------------------------------- */ - - function setState(targetKey: string, nextState: SourceControlDiscoveryState): void { - config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - const next: SourceControlDiscoveryState = - current.data === null - ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE - : { - data: current.data, - error: null, - isPending: true, - }; - - if ( - current.data === next.data && - current.error === next.error && - current.isPending === next.isPending - ) { - return; - } - - setState(targetKey, next); - } - - function setData(targetKey: string, data: SourceControlDiscoveryResult): void { - setState(targetKey, { - data, - error: null, - isPending: false, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: error instanceof Error ? error.message : "Failed to discover source control tools.", - isPending: false, - }); - } - - /* -- Public API ---------------------------------------------------- */ - - /** - * Trigger a one-shot source-control discovery RPC for a target. - * - * Calls are deduplicated while a refresh for the same target key is in - * flight. On failure, the previous successful snapshot is kept in `data` - * and the error message is stored separately so UI can keep rendering stale - * discovery results while showing the failure. - * - * @param target The logical runtime target to refresh. - * @param client Optional pre-resolved client, useful in tests. - */ - function refresh( - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): Promise { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return Promise.resolve(null); - } - refreshTargets.set(targetKey, target); - - const resolvedClient = client ?? config.getClient(targetKey); - if (!resolvedClient) { - const error = new Error("Source control discovery client is unavailable."); - setError(targetKey, error); - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) { - if (!client || existing.client === resolvedClient) { - return existing.promise; - } - - return existing.promise.then(() => refresh(target, resolvedClient)); - } - - markPending(targetKey); - const refreshVersion = getRefreshVersion(targetKey); - const promise = resolvedClient.discoverSourceControl().then( - (result) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setData(targetKey, result); - } - return result; - }, - (error: unknown) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setError(targetKey, error); - } - return getSnapshot(target).data; - }, - ); - let tracked: Promise; - tracked = promise.finally(() => { - if (refreshInFlight.get(targetKey)?.promise === tracked) { - refreshInFlight.delete(targetKey); - } - }); - refreshInFlight.set(targetKey, { - client: resolvedClient, - promise: tracked, - }); - return tracked; - } - - /** - * Reset discovery state for one target and ignore any currently in-flight - * refresh for that target. If no target is provided, every known target is - * invalidated. - */ - function invalidate(target?: SourceControlDiscoveryTarget): void { - if (!target) { - reset(); - return; - } - - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return; - } - - bumpRefreshVersion(targetKey); - refreshInFlight.delete(targetKey); - setState(targetKey, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); - } - - /** - * Read the current atom snapshot for `target`. - * - * Invalid targets return the inert empty state rather than creating a new - * family atom entry. - */ - function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; - } - - return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - } - - /** - * Keep discovery warm for `target`. - * - * Multiple callers sharing a target key are ref-counted. With - * `subscribeClientChanges`, the manager refreshes whenever a client first - * appears or is replaced after reconnect. - */ - function watch( - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): () => void { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return NOOP; - } - refreshTargets.set(targetKey, target); - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - void refresh(target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: SourceControlDiscoveryClient | null = null; - - const sync = () => { - const resolved = config.getClient(targetKey); - if (!resolved) { - currentClient = null; - markPending(targetKey); - return; - } - - if (currentClient === resolved) { - return; - } - - const isClientReplacement = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); - }; - - const unsubChanges = config.subscribeClientChanges(sync); - sync(); - teardown = unsubChanges; - } else { - if (!config.getClient(targetKey)) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function refreshWatchedTarget( - targetKey: string, - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): void { - refreshTargets.set(targetKey, target); - if (client) { - void refresh(target, client); - return; - } - - config.getRegistry().get(watchedRefreshAtom(targetKey)); - } - - /** - * Clear in-flight refresh tracking and reset every known discovery atom. - * Primarily used by tests and runtime teardown. - */ - function reset(): void { - const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]); - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - refreshTargets.clear(); - refreshInFlight.clear(); - for (const key of keys) { - bumpRefreshVersion(key); - setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); - } - } - - return { - watch, - refresh, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts new file mode 100644 index 00000000000..aa16b9cadcd --- /dev/null +++ b/packages/client-runtime/src/state/archivedThreads.test.ts @@ -0,0 +1,40 @@ +import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { expect, it } from "vite-plus/test"; + +import { + createArchivedThreadSnapshotsAtomFamily, + makeArchivedThreadsEnvironmentKey, + parseArchivedThreadsEnvironmentKey, +} from "./archivedThreads.ts"; + +it("round-trips environment keys in sorted order", () => { + const envA = EnvironmentId.make("env-a"); + const envB = EnvironmentId.make("env-b"); + const key = makeArchivedThreadsEnvironmentKey([envB, envA]); + + expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); +}); + +it("does not expose an archived snapshot failure message", () => { + const environmentId = EnvironmentId.make("env-sensitive"); + const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: () => + Atom.make( + AsyncResult.failure( + Cause.fail(new Error("credential=secret-value")), + ), + ), + labelPrefix: "test:archived-thread-snapshots", + }); + const registry = AtomRegistry.make(); + + expect(registry.get(snapshotsAtom(makeArchivedThreadsEnvironmentKey([environmentId])))).toEqual({ + snapshots: [], + error: "Failed to load archived threads.", + isLoading: false, + }); + + registry.dispose(); +}); diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts new file mode 100644 index 00000000000..8c64f1ae506 --- /dev/null +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -0,0 +1,69 @@ +import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import { pipe } from "effect/Function"; +import * as Option from "effect/Option"; +import * as Order from "effect/Order"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +export interface ArchivedSnapshotEntry { + readonly environmentId: EnvironmentId; + readonly snapshot: OrchestrationShellSnapshot; +} + +export interface ArchivedThreadSnapshotsState { + readonly snapshots: ReadonlyArray; + readonly error: string | null; + readonly isLoading: boolean; +} + +const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; +const environmentIdOrder = Order.String as Order.Order; + +export function makeArchivedThreadsEnvironmentKey( + environmentIds: ReadonlyArray, +): string { + return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => + sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), + ); +} + +export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { + if (key.length === 0) { + return []; + } + return pipe( + key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), + Arr.map((environmentId) => EnvironmentId.make(environmentId)), + ); +} + +export function createArchivedThreadSnapshotsAtomFamily(options: { + readonly getSnapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; + readonly labelPrefix: string; +}) { + return Atom.family((environmentKey: string) => + Atom.make((get): ArchivedThreadSnapshotsState => { + const snapshots: ArchivedSnapshotEntry[] = []; + let error: string | null = null; + let isLoading = false; + + for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { + const result = get(options.getSnapshotAtom(environmentId)); + isLoading ||= result.waiting; + + const snapshot = Option.getOrNull(AsyncResult.value(result)); + if (snapshot !== null) { + snapshots.push({ environmentId, snapshot }); + } + + if (error === null && result._tag === "Failure") { + error = "Failed to load archived threads."; + } + } + + return { snapshots, error, isLoading }; + }).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)), + ); +} diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts new file mode 100644 index 00000000000..58add31d6bb --- /dev/null +++ b/packages/client-runtime/src/state/assets.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createAssetEnvironmentAtoms, + InvalidAssetCollectionKeyError, + parseAssetCollectionKey, +} from "./assets.ts"; + +describe("asset collection keys", () => { + it("preserves malformed JSON and its native cause", () => { + const key = "not-json"; + let error: unknown; + + try { + parseAssetCollectionKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(InvalidAssetCollectionKeyError); + expect(error).toMatchObject({ key, cause: expect.any(SyntaxError) }); + }); + + it("rejects invalid asset collection shapes", () => { + const key = JSON.stringify(["environment-1", [{ _tag: "unknown" }]]); + + expect(() => parseAssetCollectionKey(key)).toThrowError(InvalidAssetCollectionKeyError); + }); +}); + +describe("createAssetEnvironmentAtoms", () => { + it("keys asset URL queries by environment and resource", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const assets = createAssetEnvironmentAtoms(runtime); + const environmentId = EnvironmentId.make("environment-1"); + const originalTarget = { + environmentId, + input: { + resource: { + _tag: "project-favicon" as const, + cwd: "/repo/original", + }, + }, + }; + + expect(assets.createUrl(originalTarget)).toBe( + assets.createUrl({ + environmentId, + input: { + resource: { + _tag: "project-favicon", + cwd: "/repo/original", + }, + }, + }), + ); + expect( + assets.createUrl({ + environmentId, + input: { + resource: { + _tag: "project-favicon", + cwd: "/repo/next", + }, + }, + }), + ).not.toBe(assets.createUrl(originalTarget)); + expect( + assets.createUrl({ + environmentId: EnvironmentId.make("environment-2"), + input: originalTarget.input, + }), + ).not.toBe(assets.createUrl(originalTarget)); + }); + + it("keys collections while preserving independent resource queries", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const assets = createAssetEnvironmentAtoms(runtime); + const environmentId = EnvironmentId.make("environment-1"); + const resources = [ + { _tag: "attachment" as const, attachmentId: "attachment-1" }, + { _tag: "attachment" as const, attachmentId: "attachment-2" }, + ]; + + expect(assets.createUrls({ environmentId, resources })).toBe( + assets.createUrls({ + environmentId, + resources: resources.map((resource) => ({ ...resource })), + }), + ); + expect( + assets.createUrls({ + environmentId, + resources: [...resources].toReversed(), + }), + ).not.toBe(assets.createUrls({ environmentId, resources })); + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts new file mode 100644 index 00000000000..e407f5d0028 --- /dev/null +++ b/packages/client-runtime/src/state/assets.ts @@ -0,0 +1,80 @@ +import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; +const ASSET_URL_STALE_TIME_MS = 5 * 60_000; +const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; + +export class InvalidAssetCollectionKeyError extends Schema.TaggedErrorClass()( + "InvalidAssetCollectionKeyError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid asset collection atom key: ${JSON.stringify(this.key)}.`; + } +} + +const decodeAssetCollectionKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]), +); + +export function parseAssetCollectionKey( + key: string, +): readonly [EnvironmentId, ReadonlyArray] { + try { + return decodeAssetCollectionKey(JSON.parse(key)); + } catch (cause) { + throw new InvalidAssetCollectionKeyError({ key, cause }); + } +} + +export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { + try { + return new URL(relativeUrl, httpBaseUrl).toString(); + } catch { + return null; + } +} + +export function createAssetEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const createUrl = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:assets:create-url", + tag: WS_METHODS.assetsCreateUrl, + staleTimeMs: ASSET_URL_STALE_TIME_MS, + idleTtlMs: ASSET_URL_IDLE_TTL_MS, + refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, + }); + const createUrlsFamily = Atom.family((key: string) => { + const [environmentId, resources] = parseAssetCollectionKey(key); + return Atom.make((get) => + resources.map((resource) => + get( + createUrl({ + environmentId, + input: { resource }, + }), + ), + ), + ).pipe( + Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS), + Atom.withLabel(`environment-data:assets:create-urls:${key}`), + ); + }); + + return { + createUrl, + createUrls: (target: { + readonly environmentId: EnvironmentId; + readonly resources: ReadonlyArray; + }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), + }; +} diff --git a/packages/client-runtime/src/state/auth.test.ts b/packages/client-runtime/src/state/auth.test.ts new file mode 100644 index 00000000000..b31fe617912 --- /dev/null +++ b/packages/client-runtime/src/state/auth.test.ts @@ -0,0 +1,79 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; + +import { applyAuthAccessStreamEvent, EMPTY_AUTH_ACCESS_SNAPSHOT } from "./auth.ts"; + +describe("applyAuthAccessStreamEvent", () => { + it("accumulates rapid pairing-link and client updates into one snapshot", () => { + const pairingLink = { + id: "pairing-link", + credential: "credential", + scopes: ["orchestration:read"], + subject: "subject", + label: "Phone", + createdAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-04-07T00:05:00.000Z"), + } as const; + const clientSession = { + sessionId: AuthSessionId.make("session-client"), + subject: "subject", + scopes: ["orchestration:read"], + method: "browser-session-cookie", + client: { + label: "Phone", + deviceType: "mobile", + }, + issuedAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-05-07T00:00:00.000Z"), + lastConnectedAt: null, + connected: true, + current: false, + } as const; + + const withPairingLink = applyAuthAccessStreamEvent(EMPTY_AUTH_ACCESS_SNAPSHOT, { + version: 1, + revision: 1, + type: "pairingLinkUpserted", + payload: pairingLink, + }); + const withClient = applyAuthAccessStreamEvent(withPairingLink, { + version: 1, + revision: 2, + type: "clientUpserted", + payload: clientSession, + }); + + expect(withClient).toEqual({ + pairingLinks: [pairingLink], + clientSessions: [clientSession], + }); + }); + + it("applies removals without disturbing unrelated access state", () => { + const snapshot = applyAuthAccessStreamEvent( + { + pairingLinks: [ + { + id: "pairing-link", + credential: "credential", + scopes: ["orchestration:read"], + subject: "subject", + label: "Phone", + createdAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-04-07T00:05:00.000Z"), + }, + ], + clientSessions: [], + }, + { + version: 1, + revision: 2, + type: "pairingLinkRemoved", + payload: { id: "pairing-link" }, + }, + ); + + expect(snapshot).toEqual(EMPTY_AUTH_ACCESS_SNAPSHOT); + }); +}); diff --git a/packages/client-runtime/src/state/auth.ts b/packages/client-runtime/src/state/auth.ts new file mode 100644 index 00000000000..074b89627af --- /dev/null +++ b/packages/client-runtime/src/state/auth.ts @@ -0,0 +1,90 @@ +import type { + AuthAccessSnapshot, + AuthAccessStreamEvent, + AuthAccessStreamSnapshotEvent, +} from "@t3tools/contracts"; +import { WS_METHODS } from "@t3tools/contracts"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe } from "../rpc/client.ts"; +import { createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; + +export const EMPTY_AUTH_ACCESS_SNAPSHOT: AuthAccessSnapshot = { + pairingLinks: [], + clientSessions: [], +}; + +function upsertByKey( + values: ReadonlyArray, + next: A, + key: (value: A) => string, +): ReadonlyArray { + const nextKey = key(next); + return [...values.filter((value) => key(value) !== nextKey), next]; +} + +export function applyAuthAccessStreamEvent( + current: AuthAccessSnapshot, + event: AuthAccessStreamEvent, +): AuthAccessSnapshot { + switch (event.type) { + case "snapshot": + return event.payload; + case "pairingLinkUpserted": + return { + ...current, + pairingLinks: upsertByKey(current.pairingLinks, event.payload, (value) => value.id), + }; + case "pairingLinkRemoved": + return { + ...current, + pairingLinks: current.pairingLinks.filter((value) => value.id !== event.payload.id), + }; + case "clientUpserted": + return { + ...current, + clientSessions: upsertByKey( + current.clientSessions, + event.payload, + (value) => value.sessionId, + ), + }; + case "clientRemoved": + return { + ...current, + clientSessions: current.clientSessions.filter( + (value) => value.sessionId !== event.payload.sessionId, + ), + }; + } +} + +export function projectAuthAccessSnapshot( + current: AuthAccessSnapshot, + event: AuthAccessStreamEvent, +): readonly [AuthAccessSnapshot, ReadonlyArray] { + const snapshot = applyAuthAccessStreamEvent(current, event); + const projected: AuthAccessStreamSnapshotEvent = { + version: 1, + revision: event.revision, + type: "snapshot", + payload: snapshot, + }; + return [snapshot, [projected]]; +} + +export function createAuthEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + accessChanges: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:server:auth-access-changes", + subscribe: (_input: null) => + subscribe(WS_METHODS.subscribeAuthAccess, {}).pipe( + Stream.mapAccum(() => EMPTY_AUTH_ACCESS_SNAPSHOT, projectAuthAccessSnapshot), + ), + }), + }; +} diff --git a/packages/client-runtime/src/state/checkpointDiff.ts b/packages/client-runtime/src/state/checkpointDiff.ts new file mode 100644 index 00000000000..455ceaf00d7 --- /dev/null +++ b/packages/client-runtime/src/state/checkpointDiff.ts @@ -0,0 +1,25 @@ +import type { + EnvironmentId, + OrchestrationGetFullThreadDiffResult, + OrchestrationGetTurnDiffResult, + ThreadId, +} from "@t3tools/contracts"; + +export type CheckpointDiffResult = + | OrchestrationGetTurnDiffResult + | OrchestrationGetFullThreadDiffResult; + +export interface CheckpointDiffState { + readonly data: CheckpointDiffResult | null; + readonly error: string | null; + readonly isPending: boolean; +} + +export interface CheckpointDiffTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly fromTurnCount: number | null; + readonly toTurnCount: number | null; + readonly ignoreWhitespace: boolean; + readonly cacheScope?: string | null; +} diff --git a/packages/client-runtime/src/state/composerPathSearch.ts b/packages/client-runtime/src/state/composerPathSearch.ts new file mode 100644 index 00000000000..262c9f49b5f --- /dev/null +++ b/packages/client-runtime/src/state/composerPathSearch.ts @@ -0,0 +1,19 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface ComposerPathSearchEntry { + readonly path: string; + readonly kind: "file" | "directory"; + readonly parentPath?: string; +} + +export interface ComposerPathSearchState { + readonly entries: ReadonlyArray; + readonly isPending: boolean; + readonly error: string | null; +} + +export interface ComposerPathSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string | null; +} diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts new file mode 100644 index 00000000000..6dfa5001a48 --- /dev/null +++ b/packages/client-runtime/src/state/connections.ts @@ -0,0 +1,130 @@ +import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import * as EnvironmentRegistry from "../connection/registry.ts"; +import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; +import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { + createAtomCommandScheduler, + createRuntimeCommand, + followStreamInEnvironment, +} from "./runtime.ts"; + +export interface EnvironmentCatalogState { + readonly isReady: boolean; + readonly entries: ReadonlyMap; +} + +export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ + isReady: false, + entries: new Map(), +}); + +export function createEnvironmentCatalogAtoms( + runtime: Atom.AtomRuntime, +) { + const commandScheduler = createAtomCommandScheduler(); + const serial = { mode: "serial" as const, key: () => "environment-catalog" }; + const catalogAtom = runtime.atom( + Stream.unwrap( + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.map((registry) => + SubscriptionRef.changes(registry.entries).pipe( + Stream.map((entries) => ({ + isReady: true, + entries, + })), + ), + ), + ), + ), + { initialValue: EMPTY_ENVIRONMENT_CATALOG_STATE }, + ); + + const catalogValueAtom = Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), + ).pipe(Atom.withLabel("environment-catalog-value")); + + const networkStatusAtom = runtime.atom( + Stream.unwrap( + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.map((registry) => SubscriptionRef.changes(registry.networkStatus)), + ), + ), + { initialValue: "unknown" as const }, + ); + + const networkStatusValueAtom = Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(networkStatusAtom)), () => "unknown" as const), + ).pipe(Atom.withLabel("environment-network-status-value")); + + const stateAtom = Atom.family((environmentId: EnvironmentIdType) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), + ), + ), + ), + { initialValue: AVAILABLE_CONNECTION_STATE }, + ), + ); + + const register = createRuntimeCommand(runtime, { + label: "environment-catalog:register", + scheduler: commandScheduler, + concurrency: serial, + execute: ( + target: Parameters[0], + ) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.register(target)), + ), + }); + const remove = createRuntimeCommand(runtime, { + label: "environment-catalog:remove", + scheduler: commandScheduler, + concurrency: serial, + execute: (environmentId: EnvironmentIdType) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.remove(environmentId)), + ), + }); + const removeRelayEnvironments = createRuntimeCommand(runtime, { + label: "environment-catalog:remove-relay-environments", + scheduler: commandScheduler, + concurrency: serial, + execute: (_input: void) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.removeRelayEnvironments()), + ), + }); + const retryNow = createRuntimeCommand(runtime, { + label: "environment-catalog:retry-now", + scheduler: commandScheduler, + concurrency: serial, + execute: (environmentId: EnvironmentIdType) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.retryNow(environmentId)), + ), + }); + + return { + catalogAtom, + catalogValueAtom, + networkStatusAtom, + networkStatusValueAtom, + stateAtom, + register, + remove, + removeRelayEnvironments, + retryNow, + }; +} diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts new file mode 100644 index 00000000000..ce26369ece9 --- /dev/null +++ b/packages/client-runtime/src/state/entities.test.ts @@ -0,0 +1,369 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationShellSnapshot, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import { + InvalidScopedProjectKeyError, + InvalidScopedProjectRefCollectionKeyError, + InvalidScopedThreadKeyError, + parseProjectKey, + parseProjectRefCollectionKey, + parseThreadKey, +} from "./entities.ts"; +import type { EnvironmentShellState } from "./shell.ts"; +import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; +import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; +import { createEnvironmentSnapshotAtom } from "./snapshots.ts"; +import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; +import { mergeEnvironmentThread } from "./threadDetail.ts"; +import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const OTHER_PROJECT_ID = ProjectId.make("project-2"); +const THREAD_ID = ThreadId.make("thread-1"); +const OTHER_THREAD_ID = ThreadId.make("thread-2"); + +describe("scoped entity keys", () => { + it("preserves an invalid project key as structured error data", () => { + const key = "missing-project-key-separator"; + let error: unknown; + + try { + parseProjectKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toEqual(new InvalidScopedProjectKeyError({ key })); + }); + + it("preserves an invalid thread key as structured error data", () => { + const key = "missing-thread-key-separator"; + let error: unknown; + + try { + parseThreadKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toEqual(new InvalidScopedThreadKeyError({ key })); + }); + + it("preserves malformed project reference collection input and its cause", () => { + const key = "not-json"; + let error: unknown; + + try { + parseProjectRefCollectionKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(InvalidScopedProjectRefCollectionKeyError); + expect(error).toMatchObject({ key, cause: expect.anything() }); + }); + + it("rejects invalid project reference collection shapes", () => { + const key = JSON.stringify([["environment-1"]]); + + expect(() => parseProjectRefCollectionKey(key)).toThrowError( + InvalidScopedProjectRefCollectionKeyError, + ); + }); +}); + +const THREAD_SHELL = { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + session: null, + goal: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +} as const; + +const SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + updatedAt: "2026-06-01T00:00:00.000Z", + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + { + id: OTHER_PROJECT_ID, + title: "Other project", + workspaceRoot: "/other-repo", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ], + threads: [ + THREAD_SHELL, + { + ...THREAD_SHELL, + id: OTHER_THREAD_ID, + projectId: OTHER_PROJECT_ID, + title: "Other thread", + }, + ], +}; + +function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState { + return { + snapshot: Option.some(snapshot), + status: "live", + error: Option.none(), + }; +} + +function makeHarness() { + const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => + Atom.make(AsyncResult.success(shellState(SNAPSHOT))), + ); + const threadStateAtoms = Atom.family((_key: string) => + Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)), + ); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map([ + [ + ENVIRONMENT_ID, + { + target: new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Environment", + httpBaseUrl: "https://example.test", + wsBaseUrl: "wss://example.test", + }), + profile: Option.none(), + }, + ], + ]), + }); + const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); + const projects = createEnvironmentProjectAtoms({ + catalogValueAtom, + snapshotAtom, + }); + const threadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom, + snapshotAtom, + }); + const threadDetails = createEnvironmentThreadDetailAtoms((environmentId, threadId) => + threadStateAtoms(`${environmentId}\u0000${threadId}`), + ); + + return { + registry: AtomRegistry.make(), + shellStateAtom: shellStateAtoms(ENVIRONMENT_ID), + threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`), + projects, + threadShells, + threadDetails, + }; +} + +describe("environment entity projections", () => { + it("composes detail collections with authoritative shell workspace metadata", () => { + const messages: OrchestrationThread["messages"] = []; + const detail = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + title: "Cached thread", + branch: "stale-branch", + worktreePath: "/repo/stale-worktree", + deletedAt: null, + messages, + proposedPlans: [], + activities: [], + checkpoints: [], + } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId }; + const shell = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + title: "Current thread", + branch: "current-branch", + worktreePath: "/repo/current-worktree", + }; + + const merged = mergeEnvironmentThread(detail, shell); + + expect(merged).toMatchObject({ + title: "Current thread", + branch: "current-branch", + worktreePath: "/repo/current-worktree", + }); + expect(merged?.messages).toBe(messages); + }); + + it("preserves untouched project and thread identities across unrelated shell updates", () => { + const harness = makeHarness(); + const projectRefsAtom = harness.projects.environmentProjectRefsAtom(ENVIRONMENT_ID); + const threadRefsAtom = harness.threadShells.environmentThreadRefsAtom(ENVIRONMENT_ID); + const projectsAtom = harness.projects.projectsAtom; + const projectAtom = harness.projects.projectAtom({ + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + }); + const threadAtom = harness.threadShells.threadShellAtom({ + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }); + const projectRefs = harness.registry.get(projectRefsAtom); + const threadRefs = harness.registry.get(threadRefsAtom); + const projects = harness.registry.get(projectsAtom); + const project = harness.registry.get(projectAtom); + const thread = harness.registry.get(threadAtom); + + harness.registry.set( + harness.shellStateAtom, + AsyncResult.success( + shellState({ + ...SNAPSHOT, + snapshotSequence: 2, + threads: SNAPSHOT.threads.map((candidate) => + candidate.id === OTHER_THREAD_ID + ? { ...candidate, title: "Renamed other thread" } + : candidate, + ), + }), + ), + ); + + expect(harness.registry.get(projectRefsAtom)).toBe(projectRefs); + expect(harness.registry.get(threadRefsAtom)).toBe(threadRefs); + expect(harness.registry.get(projectsAtom)).toBe(projects); + expect(harness.registry.get(projectAtom)).toBe(project); + expect(harness.registry.get(threadAtom)).toBe(thread); + }); + + it("preserves project-scoped thread collections across unrelated project updates", () => { + const harness = makeHarness(); + const projectRef = { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + }; + const refsByProjectAtom = + harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID); + const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]); + const refs = harness.registry.get(refsByProjectAtom).get(PROJECT_ID); + const threads = harness.registry.get(threadsAtom); + + expect(threads).toHaveLength(1); + expect(threads[0]?.id).toBe(THREAD_ID); + + harness.registry.set( + harness.shellStateAtom, + AsyncResult.success( + shellState({ + ...SNAPSHOT, + snapshotSequence: 2, + threads: SNAPSHOT.threads.map((thread) => + thread.id === OTHER_THREAD_ID ? { ...thread, title: "Updated elsewhere" } : thread, + ), + }), + ), + ); + + expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs); + expect(harness.registry.get(threadsAtom)).toBe(threads); + }); + + it("updates only the requested thread detail and preserves untouched field identities", () => { + const harness = makeHarness(); + const threadRef = { + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }; + const otherThreadRef = { + environmentId: ENVIRONMENT_ID, + threadId: OTHER_THREAD_ID, + }; + const threadDetailAtom = harness.threadDetails.detailAtom(threadRef); + const messagesAtom = harness.threadDetails.messagesAtom(threadRef); + const activitiesAtom = harness.threadDetails.activitiesAtom(threadRef); + const statusAtom = harness.threadDetails.statusAtom(threadRef); + const otherThreadDetailAtom = harness.threadDetails.detailAtom(otherThreadRef); + const otherValue = harness.registry.get(otherThreadDetailAtom); + const detail = { + ...THREAD_SHELL, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + } satisfies OrchestrationThread; + + harness.registry.set( + harness.threadStateAtom(THREAD_ID), + AsyncResult.success({ + data: Option.some(detail), + status: "live", + error: Option.none(), + }), + ); + + const scopedDetail = harness.registry.get(threadDetailAtom); + const messages = harness.registry.get(messagesAtom); + const activities = harness.registry.get(activitiesAtom); + + expect(scopedDetail).toEqual({ ...detail, environmentId: ENVIRONMENT_ID }); + expect(harness.registry.get(statusAtom)).toBe("live"); + expect(harness.registry.get(otherThreadDetailAtom)).toBe(otherValue); + + harness.registry.set( + harness.threadStateAtom(THREAD_ID), + AsyncResult.success({ + data: Option.some({ + ...detail, + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-06-01T00:01:00.000Z", + }, + }), + status: "live", + error: Option.none(), + }), + ); + + expect(harness.registry.get(messagesAtom)).toBe(messages); + expect(harness.registry.get(activitiesAtom)).toBe(activities); + }); +}); diff --git a/packages/client-runtime/src/state/entities.ts b/packages/client-runtime/src/state/entities.ts new file mode 100644 index 00000000000..e90f31d6da4 --- /dev/null +++ b/packages/client-runtime/src/state/entities.ts @@ -0,0 +1,125 @@ +import { + EnvironmentId, + ProjectId, + ThreadId, + type ScopedProjectRef, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class InvalidScopedProjectKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedProjectKeyError", + { + key: Schema.String, + }, +) { + override get message(): string { + return `Invalid scoped project atom key: ${JSON.stringify(this.key)}.`; + } +} + +export class InvalidScopedThreadKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedThreadKeyError", + { + key: Schema.String, + }, +) { + override get message(): string { + return `Invalid scoped thread atom key: ${JSON.stringify(this.key)}.`; + } +} + +export class InvalidScopedProjectRefCollectionKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedProjectRefCollectionKeyError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid scoped project reference collection atom key: ${JSON.stringify(this.key)}.`; + } +} + +const decodeProjectRefCollectionKey = Schema.decodeUnknownSync( + Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +); + +export function projectKey(ref: ScopedProjectRef): string { + return `${ref.environmentId}\u0000${ref.projectId}`; +} + +export function threadKey(ref: ScopedThreadRef): string { + return `${ref.environmentId}\u0000${ref.threadId}`; +} + +export function projectRefCollectionKey(refs: ReadonlyArray): string { + return JSON.stringify(refs.map((ref) => [ref.environmentId, ref.projectId])); +} + +export function parseProjectKey(key: string): ScopedProjectRef { + const separator = key.indexOf("\u0000"); + if (separator < 0) { + throw new InvalidScopedProjectKeyError({ key }); + } + return { + environmentId: EnvironmentId.make(key.slice(0, separator)), + projectId: ProjectId.make(key.slice(separator + 1)), + }; +} + +export function parseProjectRefCollectionKey(key: string): ReadonlyArray { + let entries: ReadonlyArray; + try { + entries = decodeProjectRefCollectionKey(JSON.parse(key)); + } catch (cause) { + throw new InvalidScopedProjectRefCollectionKeyError({ key, cause }); + } + return entries.map(([environmentId, projectId]) => ({ + environmentId: EnvironmentId.make(environmentId), + projectId: ProjectId.make(projectId), + })); +} + +export function parseThreadKey(key: string): ScopedThreadRef { + const separator = key.indexOf("\u0000"); + if (separator < 0) { + throw new InvalidScopedThreadKeyError({ key }); + } + return { + environmentId: EnvironmentId.make(key.slice(0, separator)), + threadId: ThreadId.make(key.slice(separator + 1)), + }; +} + +export function projectRefsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +export function threadRefsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.threadId === right[index]?.threadId, + ) + ); +} + +export function arrayElementsEqual(left: ReadonlyArray, right: ReadonlyArray): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts new file mode 100644 index 00000000000..c78b66cf316 --- /dev/null +++ b/packages/client-runtime/src/state/filesystem.ts @@ -0,0 +1,16 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createFilesystemEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + browse: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:filesystem:browse", + tag: WS_METHODS.filesystemBrowse, + }), + }; +} diff --git a/packages/client-runtime/src/state/git.ts b/packages/client-runtime/src/state/git.ts new file mode 100644 index 00000000000..8a743485b4f --- /dev/null +++ b/packages/client-runtime/src/state/git.ts @@ -0,0 +1,23 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; + +export function createGitEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + pullRequestResolution: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:git:resolve-pull-request", + tag: WS_METHODS.gitResolvePullRequest, + }), + preparePullRequestThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:git:prepare-pull-request-thread", + tag: WS_METHODS.gitPreparePullRequestThread, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + }; +} diff --git a/packages/client-runtime/src/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts similarity index 100% rename from packages/client-runtime/src/gitActions.ts rename to packages/client-runtime/src/state/gitActions.ts diff --git a/packages/client-runtime/src/shellTypes.ts b/packages/client-runtime/src/state/models.ts similarity index 52% rename from packages/client-runtime/src/shellTypes.ts rename to packages/client-runtime/src/state/models.ts index 1d3a6e35de2..b601b59bfad 100644 --- a/packages/client-runtime/src/shellTypes.ts +++ b/packages/client-runtime/src/state/models.ts @@ -1,38 +1,53 @@ import type { EnvironmentId, + OrchestrationMessage, OrchestrationProjectShell, OrchestrationShellSnapshot, + OrchestrationThread, OrchestrationThreadShell, ThreadId, } from "@t3tools/contracts"; -export interface EnvironmentScopedProjectShell extends OrchestrationProjectShell { +export interface EnvironmentProject extends OrchestrationProjectShell { readonly environmentId: EnvironmentId; } -export interface EnvironmentScopedThreadShell extends OrchestrationThreadShell { +export interface EnvironmentThreadShell extends OrchestrationThreadShell { readonly environmentId: EnvironmentId; } -export function scopeProjectShell( +export type EnvironmentMessage = OrchestrationMessage; + +export interface EnvironmentThread extends OrchestrationThread { + readonly environmentId: EnvironmentId; +} + +export function scopeProject( environmentId: EnvironmentId, project: OrchestrationProjectShell, -): EnvironmentScopedProjectShell { +): EnvironmentProject { return { ...project, environmentId }; } export function scopeThreadShell( environmentId: EnvironmentId, thread: OrchestrationThreadShell, -): EnvironmentScopedThreadShell { +): EnvironmentThreadShell { + return { ...thread, environmentId }; +} + +export function scopeThread( + environmentId: EnvironmentId, + thread: OrchestrationThread, +): EnvironmentThread { return { ...thread, environmentId }; } -export function selectScopedThreadShell( +export function selectEnvironmentThreadShell( snapshot: OrchestrationShellSnapshot | null, environmentId: EnvironmentId, threadId: ThreadId, -): EnvironmentScopedThreadShell | null { +): EnvironmentThreadShell | null { const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; return thread ? scopeThreadShell(environmentId, thread) : null; } diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts new file mode 100644 index 00000000000..f8faa49ea38 --- /dev/null +++ b/packages/client-runtime/src/state/orchestration.ts @@ -0,0 +1,24 @@ +import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createOrchestrationEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:turn-diff", + tag: ORCHESTRATION_WS_METHODS.getTurnDiff, + }), + fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:full-thread-diff", + tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, + }), + archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:archived-shell-snapshot", + tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, + }), + }; +} diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts new file mode 100644 index 00000000000..d6fed0cf5ed --- /dev/null +++ b/packages/client-runtime/src/state/presentation.ts @@ -0,0 +1,70 @@ +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts"; +import { + presentEnvironmentConnection, + type EnvironmentPresentation, +} from "../connection/presentation.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; + +function mapsEqual(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +export function createEnvironmentPresentationAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly stateAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; + /** Authoritative live server config, including streamed provider/settings updates. */ + readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + const presentationAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const entry = get(input.catalogValueAtom).entries.get(environmentId); + if (entry === undefined) { + return null; + } + const state = Option.getOrElse( + AsyncResult.value(get(input.stateAtom(environmentId))), + () => AVAILABLE_CONNECTION_STATE, + ); + return { + entry, + connection: presentEnvironmentConnection(state), + serverConfig: get(input.serverConfigValueAtom(environmentId)), + } satisfies EnvironmentPresentation; + }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)), + ); + + let previous: ReadonlyMap = new Map(); + const presentationsAtom = Atom.make((get) => { + const next = new Map(); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const presentation = get(presentationAtom(environmentId)); + if (presentation !== null) { + next.set(environmentId, presentation); + } + } + if (mapsEqual(previous, next)) { + return previous; + } + previous = next; + return previous; + }).pipe(Atom.withLabel("environment-presentations")); + + return { + presentationAtom, + presentationsAtom, + }; +} diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts new file mode 100644 index 00000000000..800fc5efac1 --- /dev/null +++ b/packages/client-runtime/src/state/preview.ts @@ -0,0 +1,107 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; + +export function createPreviewEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const lifecycleScheduler = createAtomCommandScheduler(); + const statusScheduler = createAtomCommandScheduler(); + const automationScheduler = createAtomCommandScheduler(); + const lifecycleConcurrency = { + mode: "serial" as const, + key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => + JSON.stringify([environmentId, input.threadId]), + }; + return { + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:preview:list", + tag: WS_METHODS.previewList, + staleTimeMs: 5_000, + }), + events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:events", + tag: WS_METHODS.subscribePreviewEvents, + }), + discoveredServers: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:discovered-servers", + tag: WS_METHODS.subscribeDiscoveredLocalServers, + }), + automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:automation-requests", + tag: WS_METHODS.previewAutomationConnect, + // Automation requests are commands, not cached query data. Dispose the + // stream immediately with its owner so stale requests cannot replay when + // a thread remounts and the server can clear disconnected hosts promptly. + idleTtlMs: 0, + }), + open: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:open", + tag: WS_METHODS.previewOpen, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + navigate: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:navigate", + tag: WS_METHODS.previewNavigate, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + refresh: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:refresh", + tag: WS_METHODS.previewRefresh, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + close: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:close", + tag: WS_METHODS.previewClose, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + reportStatus: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:report-status", + tag: WS_METHODS.previewReportStatus, + scheduler: statusScheduler, + concurrency: { + mode: "latest", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.threadId, input.tabId]), + }, + }), + respondToAutomation: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:automation-respond", + tag: WS_METHODS.previewAutomationRespond, + scheduler: automationScheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]), + }, + }), + reportAutomationOwner: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:automation-report-owner", + tag: WS_METHODS.previewAutomationReportOwner, + scheduler: automationScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), + }, + }), + clearAutomationOwner: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:automation-clear-owner", + tag: WS_METHODS.previewAutomationClearOwner, + scheduler: automationScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), + }, + }), + }; +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts new file mode 100644 index 00000000000..3defcc32154 --- /dev/null +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -0,0 +1,106 @@ +import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentCommand, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import { + type CreateProjectInput, + type DeleteProjectInput, + type UpdateProjectInput, + createProject, + deleteProject, + updateProject, +} from "../operations/commands.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export type { + CreateProjectInput, + DeleteProjectInput, + UpdateProjectInput, +} from "../operations/commands.ts"; + +export interface OptimisticProjectFile { + readonly data: ProjectReadFileResult; + readonly confirmedAgainst: object | null | undefined; +} + +export interface OptimisticProjectFileTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly relativePath: string; +} + +function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { + return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); +} + +export function createProjectEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const projectScheduler = createAtomCommandScheduler(); + const fileScheduler = createAtomCommandScheduler(); + const optimisticFileFamily = Atom.family((key: string) => + Atom.make(null).pipe( + Atom.withLabel(`environment-data:projects:optimistic-file:${key}`), + ), + ); + const projectConcurrency = { + mode: "serial" as const, + key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) => + JSON.stringify([environmentId, input.projectId]), + }; + return { + searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:search-entries", + tag: WS_METHODS.projectsSearchEntries, + staleTimeMs: 15_000, + }), + listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:list-entries", + tag: WS_METHODS.projectsListEntries, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), + readFile: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:read-file", + tag: WS_METHODS.projectsReadFile, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), + optimisticFile: (target: OptimisticProjectFileTarget) => + optimisticFileFamily(optimisticProjectFileKey(target)), + create: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:create", + execute: (input: CreateProjectInput) => createProject(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), + update: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:update", + execute: (input: UpdateProjectInput) => updateProject(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), + delete: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:delete", + execute: (input: DeleteProjectInput) => deleteProject(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), + writeFile: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:write-file", + tag: WS_METHODS.projectsWriteFile, + scheduler: fileScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.cwd, input.relativePath]), + }, + }), + }; +} diff --git a/packages/client-runtime/src/state/projectEntities.ts b/packages/client-runtime/src/state/projectEntities.ts new file mode 100644 index 00000000000..4d51b4d427e --- /dev/null +++ b/packages/client-runtime/src/state/projectEntities.ts @@ -0,0 +1,105 @@ +import type { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationShellSnapshot, + ProjectId, + ScopedProjectRef, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentProject } from "./models.ts"; +import { scopeProject } from "./models.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; + +const EMPTY_PROJECTS: ReadonlyArray = Object.freeze([]); +const EMPTY_PROJECT_INDEX: ReadonlyMap = new Map(); + +export function createEnvironmentProjectAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly snapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlyArray => + get(input.snapshotAtom(environmentId))?.projects ?? EMPTY_PROJECTS, + ).pipe(Atom.withLabel(`environment-projects:${environmentId}`)), + ); + + const environmentProjectIndexAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyMap => { + const projects = get(environmentProjectsAtom(environmentId)); + if (projects.length === 0) { + return EMPTY_PROJECT_INDEX; + } + return new Map(projects.map((project) => [project.id, project] as const)); + }).pipe(Atom.withLabel(`environment-project-index:${environmentId}`)), + ); + + const environmentProjectRefsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(environmentProjectsAtom(environmentId)).map((project) => ({ + environmentId, + projectId: project.id, + })); + if (projectRefsEqual(previous, next)) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-project-refs:${environmentId}`)); + }); + + const projectAtomFamily = Atom.family((key: string) => { + const ref = parseProjectKey(key); + let previousSource: OrchestrationProjectShell | null = null; + let previousValue: EnvironmentProject | null = null; + return Atom.make((get) => { + const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null; + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeProject(ref.environmentId, source); + return previousValue; + }).pipe(Atom.withLabel(`environment-project:${key}`)); + }); + + let previousProjectRefs: ReadonlyArray = []; + const projectRefsAtom = Atom.make((get) => { + const refs: ScopedProjectRef[] = []; + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + refs.push(...get(environmentProjectRefsAtom(environmentId))); + } + if (projectRefsEqual(previousProjectRefs, refs)) { + return previousProjectRefs; + } + previousProjectRefs = refs; + return refs; + }).pipe(Atom.withLabel("environment-project-refs")); + + let previousProjects: ReadonlyArray = []; + const projectsAtom = Atom.make((get) => { + const next = get(projectRefsAtom).flatMap((ref) => { + const project = get(projectAtomFamily(projectKey(ref))); + return project === null ? [] : [project]; + }); + if (arrayElementsEqual(previousProjects, next)) { + return previousProjects; + } + previousProjects = next; + return previousProjects; + }).pipe(Atom.withLabel("environment-project-list")); + + return { + environmentProjectsAtom, + environmentProjectIndexAtom, + environmentProjectRefsAtom, + projectRefsAtom, + projectsAtom, + projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)), + }; +} diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts new file mode 100644 index 00000000000..ca804c13809 --- /dev/null +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -0,0 +1,183 @@ +import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; +import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { ClientSettings } from "@t3tools/contracts/settings"; + +import type { EnvironmentProject } from "./models.ts"; +import { normalizeProjectPathForComparison } from "./projects.ts"; + +export interface ProjectGroupingSettings { + readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode; + readonly sidebarProjectGroupingOverrides: Record; +} + +export type ProjectGroupingMode = SidebarProjectGroupingMode; + +export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings { + return { + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + }; +} + +function uniqueNonEmptyValues(values: ReadonlyArray): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function deriveRepositoryRelativeProjectPath( + project: Pick, +): string | null { + const rootPath = project.repositoryIdentity?.rootPath?.trim(); + if (!rootPath) { + return null; + } + + const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); + const normalizedRootPath = normalizeProjectPathForComparison(rootPath); + if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { + return null; + } + + if (normalizedProjectPath === normalizedRootPath) { + return ""; + } + + const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; + const rootPrefix = `${normalizedRootPath}${separator}`; + if (!normalizedProjectPath.startsWith(rootPrefix)) { + return null; + } + + return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); +} + +export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { + return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; +} + +export function derivePhysicalProjectKey( + project: Pick, +): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); +} + +export function deriveProjectGroupingOverrideKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function getProjectOrderKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function resolveProjectGroupingMode( + project: Pick, + settings: ProjectGroupingSettings, +): SidebarProjectGroupingMode { + return ( + settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingMode + ); +} + +function deriveRepositoryScopedKey( + project: Pick, + groupingMode: SidebarProjectGroupingMode, +): string | null { + const canonicalKey = project.repositoryIdentity?.canonicalKey; + if (!canonicalKey) { + return null; + } + + if (groupingMode === "repository") { + return canonicalKey; + } + + const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); + if (relativeProjectPath === null) { + return canonicalKey; + } + + return relativeProjectPath.length === 0 + ? canonicalKey + : `${canonicalKey}::${relativeProjectPath}`; +} + +export function deriveLogicalProjectKey( + project: Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + >, + options?: { + readonly groupingMode?: SidebarProjectGroupingMode; + }, +): string { + const groupingMode = options?.groupingMode ?? "repository"; + if (groupingMode === "separate") { + return derivePhysicalProjectKey(project); + } + + return ( + deriveRepositoryScopedKey(project, groupingMode) ?? + derivePhysicalProjectKey(project) ?? + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) + ); +} + +export function deriveLogicalProjectKeyFromSettings( + project: Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + >, + settings: ProjectGroupingSettings, +): string { + return deriveLogicalProjectKey(project, { + groupingMode: resolveProjectGroupingMode(project, settings), + }); +} + +export function deriveLogicalProjectKeyFromRef( + projectRef: ScopedProjectRef, + project: + | Pick + | null + | undefined, + options?: { + readonly groupingMode?: SidebarProjectGroupingMode; + }, +): string { + return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); +} + +export function deriveProjectGroupLabel(input: { + readonly representative: Pick; + readonly members: ReadonlyArray>; +}): string { + const sharedDisplayNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.displayName), + ); + if (sharedDisplayNames.length === 1) { + return sharedDisplayNames[0]!; + } + + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + if (sharedRepositoryNames.length === 1) { + return sharedRepositoryNames[0]!; + } + + return input.representative.title; +} diff --git a/packages/client-runtime/src/projectPaths.ts b/packages/client-runtime/src/state/projects.ts similarity index 98% rename from packages/client-runtime/src/projectPaths.ts rename to packages/client-runtime/src/state/projects.ts index a4d2c7e19ee..82a43350650 100644 --- a/packages/client-runtime/src/projectPaths.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -5,9 +5,9 @@ import { isWindowsDrivePath, } from "@t3tools/shared/path"; -function isWindowsPlatform(platform: string): boolean { +const isWindowsPlatform = (platform: string): boolean => { return /^win(dows)?/i.test(platform); -} +}; function isRootPath(value: string): boolean { return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); @@ -219,3 +219,6 @@ export function getBrowseParentPath(currentPath: string): string | null { export function canNavigateUp(currentPath: string): boolean { return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; } + +export * from "./projectCommands.ts"; +export * from "./projectEntities.ts"; diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts new file mode 100644 index 00000000000..bdf217d0880 --- /dev/null +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -0,0 +1,41 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import { createRuntimeCommand } from "./runtime.ts"; + +export function createRelayEnvironmentDiscoveryAtoms( + runtime: Atom.AtomRuntime, +) { + const stateAtom = runtime.atom( + Stream.unwrap( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( + Effect.map((discovery) => SubscriptionRef.changes(discovery.state)), + ), + ), + { initialValue: RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, + ); + const stateValueAtom = Atom.make((get) => + Option.getOrElse( + AsyncResult.value(get(stateAtom)), + () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + ).pipe(Atom.withLabel("relay-environment-discovery-value")); + const refresh = createRuntimeCommand(runtime, { + label: "relay-environment-discovery:refresh", + concurrency: { mode: "singleFlight", key: () => "refresh" }, + execute: (_input: void) => + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( + Effect.flatMap((discovery) => discovery.refresh), + ), + }); + + return { + stateAtom, + stateValueAtom, + refresh, + }; +} diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts new file mode 100644 index 00000000000..0d78d6edd9f --- /dev/null +++ b/packages/client-runtime/src/state/review.ts @@ -0,0 +1,17 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createReviewEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:review:diff-preview", + tag: WS_METHODS.reviewGetDiffPreview, + staleTimeMs: 5_000, + }), + }; +} diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts new file mode 100644 index 00000000000..7584e55d52e --- /dev/null +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -0,0 +1,451 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Latch from "effect/Latch"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + environmentRpcKey, + createAtomCommandScheduler, + createRuntimeCommand, + executeAtomCommand, + executeAtomQuery, + isAtomCommandInterrupted, + mapAtomCommandResult, + runAtomCommand, + settleAsyncResult, + settlePromise, + squashAtomCommandFailure, +} from "./runtime.ts"; + +describe("settleAsyncResult", () => { + it("preserves successful values and typed failures", async () => { + const success = await settleAsyncResult(() => Promise.resolve(Exit.succeed("done"))); + expect(AsyncResult.isSuccess(success)).toBe(true); + if (AsyncResult.isSuccess(success)) { + expect(success.value).toBe("done"); + } + + const expectedFailure = new Error("request failed"); + const failure = await settleAsyncResult(() => Promise.resolve(Exit.fail(expectedFailure))); + expect(AsyncResult.isFailure(failure)).toBe(true); + if (AsyncResult.isFailure(failure)) { + expect(Cause.hasDies(failure.cause)).toBe(false); + expect(Cause.squash(failure.cause)).toBe(expectedFailure); + } + }); + + it("encodes thrown and rejected promises as defects", async () => { + const thrownDefect = new Error("thrown defect"); + const thrown = await settleAsyncResult(() => { + throw thrownDefect; + }); + expect(AsyncResult.isFailure(thrown)).toBe(true); + if (AsyncResult.isFailure(thrown)) { + expect(Cause.hasDies(thrown.cause)).toBe(true); + expect(Cause.squash(thrown.cause)).toBe(thrownDefect); + } + + const rejectedDefect = new Error("rejected defect"); + const rejected = await settleAsyncResult(() => Promise.reject(rejectedDefect)); + expect(AsyncResult.isFailure(rejected)).toBe(true); + if (AsyncResult.isFailure(rejected)) { + expect(Cause.hasDies(rejected.cause)).toBe(true); + expect(Cause.squash(rejected.cause)).toBe(rejectedDefect); + } + }); +}); + +describe("atom command result helpers", () => { + it("maps successful command values", () => { + const result = mapAtomCommandResult(AsyncResult.success(2), (value) => value * 3); + + expect(result._tag).toBe("Success"); + if (result._tag === "Success") { + expect(result.value).toBe(6); + } + }); + + it("preserves failures while mapping", () => { + const result = mapAtomCommandResult( + AsyncResult.failure(Cause.fail("nope")), + (value) => value * 3, + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBe("nope"); + } + }); + + it("distinguishes interruption from other failures", () => { + const interrupted = AsyncResult.failure(Cause.interrupt(1)); + const failed = AsyncResult.failure(Cause.fail("nope")); + + expect(isAtomCommandInterrupted(interrupted)).toBe(true); + expect(isAtomCommandInterrupted(failed)).toBe(false); + expect(squashAtomCommandFailure(failed)).toBe("nope"); + }); + + it("settles raw promise boundaries as successes or defects", async () => { + const success = await settlePromise(() => Promise.resolve("done")); + expect(success._tag).toBe("Success"); + + const defect = new Error("raw promise rejected"); + const failure = await settlePromise(() => Promise.reject(defect)); + expect(failure._tag).toBe("Failure"); + if (failure._tag === "Failure") { + expect(Cause.hasDies(failure.cause)).toBe(true); + expect(Cause.squash(failure.cause)).toBe(defect); + } + }); + + it("reports expected failures and defects through separate policies", async () => { + const warnings: string[] = []; + const errors: string[] = []; + const reporter = { + warn: (message: string) => { + warnings.push(message); + }, + error: (message: string) => { + errors.push(message); + }, + }; + + await executeAtomCommand(() => Promise.resolve(Exit.fail("nope")), { label: "save" }, reporter); + await executeAtomCommand( + () => Promise.resolve(Exit.fail("ignored")), + { label: "quiet save", reportFailure: false }, + reporter, + ); + await executeAtomCommand( + () => Promise.reject(new Error("defect")), + { label: "quiet save", reportFailure: false }, + reporter, + ); + await executeAtomCommand( + () => Promise.resolve(Exit.interrupt(1)), + { label: "interrupted" }, + reporter, + ); + + expect(warnings).toEqual(["[atom-command] save failed"]); + expect(errors).toEqual(["[atom-command] quiet save defected"]); + }); +}); + +describe("environmentRpcKey", () => { + it("isolates subscription state by environment and cwd", () => { + const environmentId = EnvironmentId.make("environment-1"); + const originalTarget = { + environmentId, + input: { cwd: "/repo/original" }, + }; + const nextTarget = { + environmentId, + input: { cwd: "/repo/next" }, + }; + + expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextTarget)); + expect(environmentRpcKey(originalTarget)).toBe(environmentRpcKey({ ...originalTarget })); + expect( + environmentRpcKey({ + environmentId: EnvironmentId.make("environment-2"), + input: originalTarget.input, + }), + ).not.toBe(environmentRpcKey(originalTarget)); + }); +}); + +describe("Atom.fn mutation semantics", () => { + it.effect("interrupts the previous invocation when the same mutation atom is written again", () => + Effect.gen(function* () { + const firstLatch = Latch.makeUnsafe(); + const secondLatch = Latch.makeUnsafe(); + const interrupted: string[] = []; + const mutation = Atom.fn((id: "first" | "second") => + (id === "first" ? firstLatch : secondLatch).await.pipe( + Effect.as(id), + Effect.onInterrupt(() => + Effect.sync(() => { + interrupted.push(id); + }), + ), + ), + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(mutation); + + registry.set(mutation, "first"); + registry.set(mutation, "second"); + yield* Effect.yieldNow; + + expect(interrupted).toEqual(["first"]); + + secondLatch.openUnsafe(); + expect( + yield* AtomRegistry.getResult(registry, mutation, { + suspendOnWaiting: true, + }), + ).toBe("second"); + + unmount(); + registry.dispose(); + }), + ); + + it.effect("keeps stream mutations waiting until the final emitted value", () => + Effect.gen(function* () { + const completionLatch = Latch.makeUnsafe(); + const mutation = Atom.fn(() => + Stream.make("progress").pipe( + Stream.concat(Stream.fromEffect(completionLatch.await.pipe(Effect.as("done")))), + ), + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(mutation); + + registry.set(mutation, undefined); + + const progress = registry.get(mutation); + expect(AsyncResult.isSuccess(progress)).toBe(true); + if (AsyncResult.isSuccess(progress)) { + expect(progress.value).toBe("progress"); + expect(progress.waiting).toBe(true); + } + + completionLatch.openUnsafe(); + expect( + yield* AtomRegistry.getResult(registry, mutation, { + suspendOnWaiting: true, + }), + ).toBe("done"); + + unmount(); + registry.dispose(); + }), + ); + + it.effect( + "allows concurrent effects to finish but does not correlate results to individual writes", + () => + Effect.gen(function* () { + const firstLatch = Latch.makeUnsafe(); + const secondLatch = Latch.makeUnsafe(); + const mutation = Atom.fn( + (id: "first" | "second") => + (id === "first" ? firstLatch : secondLatch).await.pipe(Effect.as(id)), + { concurrent: true }, + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(mutation); + + registry.set(mutation, "first"); + const firstResult = yield* AtomRegistry.getResult(registry, mutation, { + suspendOnWaiting: true, + }).pipe(Effect.forkChild({ startImmediately: true })); + registry.set(mutation, "second"); + const secondResult = yield* AtomRegistry.getResult(registry, mutation, { + suspendOnWaiting: true, + }).pipe(Effect.forkChild({ startImmediately: true })); + + secondLatch.openUnsafe(); + yield* Effect.yieldNow; + + const stillWaiting = registry.get(mutation); + expect(stillWaiting.waiting).toBe(true); + + firstLatch.openUnsafe(); + + expect(yield* Fiber.join(firstResult)).toBe("first"); + expect(yield* Fiber.join(secondResult)).toBe("first"); + + unmount(); + registry.dispose(); + }), + ); +}); + +describe("executeAtomQuery", () => { + it("keeps concurrent query results correlated to their atoms", async () => { + const firstLatch = Latch.makeUnsafe(); + const secondLatch = Latch.makeUnsafe(); + const firstAtom = Atom.make(firstLatch.await.pipe(Effect.as("first"))); + const secondAtom = Atom.make(secondLatch.await.pipe(Effect.as("second"))); + const registry = AtomRegistry.make(); + + const firstResult = executeAtomQuery(registry, firstAtom); + const secondResult = executeAtomQuery(registry, secondAtom); + + secondLatch.openUnsafe(); + firstLatch.openUnsafe(); + + const [first, second] = await Promise.all([firstResult, secondResult]); + expect(first._tag).toBe("Success"); + expect(second._tag).toBe("Success"); + if (first._tag === "Success" && second._tag === "Success") { + expect(first.value).toBe("first"); + expect(second.value).toBe("second"); + } + + registry.dispose(); + }); +}); + +describe("runtime command runner", () => { + it("encodes custom command rejections as defects", async () => { + const defect = new Error("custom command rejected"); + const registry = AtomRegistry.make(); + const result = await runAtomCommand( + registry, + { + label: "test.rejected-command", + run: () => Promise.reject(defect), + }, + undefined, + { reportDefect: false }, + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.hasDies(result.cause)).toBe(true); + expect(Cause.squash(result.cause)).toBe(defect); + } + registry.dispose(); + }); + + it("settles generated command scheduler defects from direct callers", async () => { + const defect = new Error("invalid command key"); + const runtime = Atom.runtime(Layer.empty); + const command = createRuntimeCommand(runtime, { + label: "test.invalid-key", + concurrency: { + mode: "serial", + key: () => { + throw defect; + }, + }, + execute: () => Effect.void, + }); + const registry = AtomRegistry.make(); + + const result = await command.run(registry, undefined); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.hasDies(result.cause)).toBe(true); + expect(Cause.squash(result.cause)).toBe(defect); + } + registry.dispose(); + }); + + it("correlates parallel invocation results", async () => { + const firstLatch = Latch.makeUnsafe(); + const secondLatch = Latch.makeUnsafe(); + const runtime = Atom.runtime(Layer.empty); + const command = createRuntimeCommand(runtime, { + label: "test.parallel", + execute: (id: "first" | "second") => + (id === "first" ? firstLatch : secondLatch).await.pipe(Effect.as(id)), + }); + const registry = AtomRegistry.make(); + + const first = command.run(registry, "first"); + const second = command.run(registry, "second"); + secondLatch.openUnsafe(); + firstLatch.openUnsafe(); + + expect(await first).toMatchObject({ _tag: "Success", value: "first", waiting: false }); + expect(await second).toMatchObject({ _tag: "Success", value: "second", waiting: false }); + registry.dispose(); + }); + + it("serializes commands that share a scheduler and lane", async () => { + const firstLatch = Latch.makeUnsafe(); + const events: string[] = []; + const runtime = Atom.runtime(Layer.empty); + const scheduler = createAtomCommandScheduler(); + const concurrency = { mode: "serial" as const, key: () => "shared" }; + const firstCommand = createRuntimeCommand(runtime, { + label: "test.first", + scheduler, + concurrency, + execute: () => + Effect.sync(() => events.push("first:start")).pipe( + Effect.andThen(firstLatch.await), + Effect.tap(() => Effect.sync(() => events.push("first:end"))), + ), + }); + const secondCommand = createRuntimeCommand(runtime, { + label: "test.second", + scheduler, + concurrency, + execute: () => Effect.sync(() => events.push("second:start")), + }); + const registry = AtomRegistry.make(); + + const first = firstCommand.run(registry, undefined); + const second = secondCommand.run(registry, undefined); + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + + firstLatch.openUnsafe(); + await Promise.all([first, second]); + expect(events).toEqual(["first:start", "first:end", "second:start"]); + registry.dispose(); + }); + + it("deduplicates single-flight commands by key", async () => { + const latch = Latch.makeUnsafe(); + let executions = 0; + const runtime = Atom.runtime(Layer.empty); + const command = createRuntimeCommand(runtime, { + label: "test.single-flight", + concurrency: { mode: "singleFlight", key: (key: string) => key }, + execute: () => + Effect.sync(() => executions++).pipe(Effect.andThen(latch.await), Effect.as("done")), + }); + const registry = AtomRegistry.make(); + + const first = command.run(registry, "same"); + const second = command.run(registry, "same"); + latch.openUnsafe(); + + expect(await first).toMatchObject({ _tag: "Success", value: "done", waiting: false }); + expect(await second).toMatchObject({ _tag: "Success", value: "done", waiting: false }); + expect(executions).toBe(1); + registry.dispose(); + }); + + it("coalesces pending latest-value commands without interrupting the active call", async () => { + const firstLatch = Latch.makeUnsafe(); + const executed: number[] = []; + const runtime = Atom.runtime(Layer.empty); + const command = createRuntimeCommand(runtime, { + label: "test.latest", + concurrency: { mode: "latest", key: () => "shared" }, + execute: (value: number) => + Effect.sync(() => executed.push(value)).pipe( + Effect.andThen(value === 1 ? firstLatch.await : Effect.void), + Effect.as(value), + ), + }); + const registry = AtomRegistry.make(); + + const first = command.run(registry, 1); + await Promise.resolve(); + const second = command.run(registry, 2); + const third = command.run(registry, 3); + firstLatch.openUnsafe(); + + expect(await first).toMatchObject({ _tag: "Success", value: 1, waiting: false }); + expect(await second).toMatchObject({ _tag: "Success", value: 3, waiting: false }); + expect(await third).toMatchObject({ _tag: "Success", value: 3, waiting: false }); + expect(executed).toEqual([1, 3]); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts new file mode 100644 index 00000000000..7bfeb81f5db --- /dev/null +++ b/packages/client-runtime/src/state/runtime.ts @@ -0,0 +1,651 @@ +import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts"; +import { + type EnvironmentRpcInput, + type EnvironmentRpcStreamFailure, + type EnvironmentRpcStreamValue, + type EnvironmentStreamCommandRpcTag, + type EnvironmentSubscriptionRpcTag, + type EnvironmentUnaryRpcTag, + request, + runStream, + subscribe, +} from "../rpc/client.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; + +interface EnvironmentAtomOptions { + readonly label: string; + readonly execute: (input: Input) => Effect.Effect; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentIdType; + readonly input: Input; + }>; +} + +interface EnvironmentQueryAtomOptions extends EnvironmentAtomOptions< + Input, + A, + E, + R +> { + readonly staleTimeMs?: number; + readonly idleTtlMs?: number; + readonly refreshIntervalMs?: number; +} + +interface EnvironmentSubscriptionAtomOptions { + readonly label: string; + readonly subscribe: (input: Input) => Stream.Stream; + readonly idleTtlMs?: number; +} + +export type SettledAsyncResult = AsyncResult.Success | AsyncResult.Failure; + +export type AtomCommandResult = SettledAsyncResult; + +export type AtomCommandSuccess = R extends AtomCommandResult ? A : never; + +export type AtomCommandFailure = R extends AtomCommandResult ? E : never; + +export interface AtomCommandOptions { + readonly label?: string; + readonly reportFailure?: boolean; + readonly reportDefect?: boolean; +} + +export interface AtomCommandReporter { + readonly warn: (message: string, cause: Cause.Cause) => void; + readonly error: (message: string, cause: Cause.Cause) => void; +} + +export interface AtomCommand { + readonly label: string; + readonly run: (registry: AtomRegistry.AtomRegistry, input: W) => Promise>; +} + +export type AtomCommandConcurrency = + /** Every invocation runs independently. */ + | { readonly mode: "parallel" } + | { + /** + * `serial` preserves every invocation in FIFO order, `singleFlight` shares an active + * invocation, and `latest` coalesces queued invocations to the newest input. + */ + readonly mode: "serial" | "singleFlight" | "latest"; + readonly key: (input: W) => string; + }; + +interface AtomCommandSchedulerState { + readonly serial: Map>; + readonly singleFlight: Map>; + readonly latest: Map; +} + +interface AtomCommandLatestBatch { + execute: () => Promise>; + readonly resolve: Array<(result: AtomCommandResult) => void>; +} + +interface AtomCommandLatestLane { + running: boolean; + pending: AtomCommandLatestBatch | undefined; +} + +export interface AtomCommandScheduler { + readonly schedule: ( + registry: AtomRegistry.AtomRegistry, + concurrency: AtomCommandConcurrency, + input: W, + execute: () => Promise>, + ) => Promise>; +} + +async function settleAtomCommandResult( + execute: () => Promise>, +): Promise> { + try { + return await execute(); + } catch (defect) { + return AsyncResult.failure(Cause.die(defect)); + } +} + +export function createAtomCommandScheduler(): AtomCommandScheduler { + const registryStates = new WeakMap(); + + const stateFor = (registry: AtomRegistry.AtomRegistry): AtomCommandSchedulerState => { + const existing = registryStates.get(registry); + if (existing !== undefined) { + return existing; + } + const state: AtomCommandSchedulerState = { + serial: new Map(), + singleFlight: new Map(), + latest: new Map(), + }; + registryStates.set(registry, state); + return state; + }; + + return { + schedule: ( + registry: AtomRegistry.AtomRegistry, + concurrency: AtomCommandConcurrency, + input: W, + execute: () => Promise>, + ): Promise> => { + if (concurrency.mode === "parallel") { + return execute(); + } + + const key = concurrency.key(input); + const state = stateFor(registry); + if (concurrency.mode === "singleFlight") { + const existing = state.singleFlight.get(key) as + | Promise> + | undefined; + if (existing !== undefined) { + return existing; + } + const current = execute(); + state.singleFlight.set(key, current); + void current.then( + () => { + if (state.singleFlight.get(key) === current) { + state.singleFlight.delete(key); + } + }, + () => { + if (state.singleFlight.get(key) === current) { + state.singleFlight.delete(key); + } + }, + ); + return current; + } + + if (concurrency.mode === "serial") { + const previous = state.serial.get(key); + const current = previous === undefined ? execute() : previous.then(execute, execute); + state.serial.set(key, current); + void current.then( + () => { + if (state.serial.get(key) === current) { + state.serial.delete(key); + } + }, + () => { + if (state.serial.get(key) === current) { + state.serial.delete(key); + } + }, + ); + return current; + } + + let lane = state.latest.get(key); + if (lane === undefined) { + lane = { running: false, pending: undefined }; + state.latest.set(key, lane); + } + const activeLane = lane; + + const result = new Promise>((resolve) => { + if (activeLane.pending === undefined) { + activeLane.pending = { + execute: execute as () => Promise>, + resolve: [resolve as (result: AtomCommandResult) => void], + }; + return; + } + activeLane.pending.execute = execute as () => Promise>; + activeLane.pending.resolve.push( + resolve as (result: AtomCommandResult) => void, + ); + }); + + if (!activeLane.running) { + activeLane.running = true; + void (async () => { + while (activeLane.pending !== undefined) { + const batch = activeLane.pending; + activeLane.pending = undefined; + let batchResult: AtomCommandResult; + try { + batchResult = await batch.execute(); + } catch (defect) { + batchResult = AsyncResult.failure(Cause.die(defect)); + } + for (const resolve of batch.resolve) { + resolve(batchResult); + } + } + activeLane.running = false; + if (state.latest.get(key) === activeLane) { + state.latest.delete(key); + } + })(); + } + + return result; + }, + }; +} + +export async function runAtomCommand( + registry: AtomRegistry.AtomRegistry, + command: AtomCommand, + input: W, + options: AtomCommandOptions = {}, + reporter: AtomCommandReporter = console, +): Promise> { + const result = await settleAtomCommandResult(() => command.run(registry, input)); + reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter); + return result; +} + +export function mapAtomCommandResult( + result: AtomCommandResult, + map: (value: A) => B, +): AtomCommandResult { + return result._tag === "Success" + ? AsyncResult.success(map(result.value)) + : AsyncResult.failure(result.cause); +} + +export function isAtomCommandInterrupted(result: AtomCommandResult): boolean { + return result._tag === "Failure" && Cause.hasInterruptsOnly(result.cause); +} + +export function squashAtomCommandFailure(result: { + readonly cause: Cause.Cause; +}): unknown { + return Cause.squash(result.cause); +} + +export async function settleAsyncResult( + execute: () => Promise>, +): Promise> { + try { + return AsyncResult.fromExit(await execute()); + } catch (defect) { + return AsyncResult.failure(Cause.die(defect)); + } +} + +export async function executeAtomCommand( + execute: () => Promise>, + options: AtomCommandOptions = {}, + reporter: AtomCommandReporter = console, +): Promise> { + const result = await settleAsyncResult(execute); + reportAtomCommandResult(result, options, reporter); + return result; +} + +export async function executeAtomQuery( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Atom>, + options: AtomCommandOptions = {}, + reporter: AtomCommandReporter = console, +): Promise> { + const query = Effect.scoped( + Effect.gen(function* () { + yield* AtomRegistry.mount(registry, atom); + return yield* AtomRegistry.getResult(registry, atom, { + suspendOnWaiting: true, + }); + }), + ); + return executeAtomCommand(() => Effect.runPromiseExit(query), options, reporter); +} + +export function createRuntimeCommand( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly execute: (input: W, registry: AtomRegistry.AtomRegistry) => Effect.Effect; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency; + }, +): AtomCommand { + const scheduler = options.scheduler ?? createAtomCommandScheduler(); + const concurrency = options.concurrency ?? { mode: "parallel" as const }; + return { + label: options.label, + run: (registry, input) => + settleAtomCommandResult(() => + scheduler.schedule(registry, concurrency, input, () => { + const atom = runtime + .atom(options.execute(input, registry)) + .pipe(Atom.withLabel(options.label)); + return executeAtomQuery(registry, atom, { reportDefect: false, reportFailure: false }); + }), + ), + }; +} + +export function createRuntimeStreamCommand( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly execute: (input: W, registry: AtomRegistry.AtomRegistry) => Stream.Stream; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency; + }, +): AtomCommand { + const scheduler = options.scheduler ?? createAtomCommandScheduler(); + const concurrency = options.concurrency ?? { mode: "parallel" as const }; + return { + label: options.label, + run: (registry, input) => + settleAtomCommandResult(() => + scheduler.schedule(registry, concurrency, input, () => { + const atom = runtime + .atom(options.execute(input, registry)) + .pipe(Atom.withLabel(options.label)); + return executeAtomQuery(registry, atom, { reportDefect: false, reportFailure: false }); + }), + ), + }; +} + +export function reportAtomCommandResult( + result: AtomCommandResult, + options: AtomCommandOptions = {}, + reporter: AtomCommandReporter = console, +): void { + if (AsyncResult.isSuccess(result) || Cause.hasInterruptsOnly(result.cause)) { + return; + } + + const label = options.label ?? "atom command"; + if (Cause.hasDies(result.cause)) { + if (options.reportDefect ?? true) { + reporter.error(`[atom-command] ${label} defected`, result.cause); + } + } else if (options.reportFailure ?? true) { + reporter.warn(`[atom-command] ${label} failed`, result.cause); + } +} + +export async function settlePromise( + execute: () => Promise, +): Promise> { + try { + return AsyncResult.success(await execute()); + } catch (defect) { + return AsyncResult.failure(Cause.die(defect)); + } +} + +export function environmentRpcKey(target: { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +}): string { + return JSON.stringify([target.environmentId, target.input]); +} + +function parseEnvironmentRpcKey(key: string): { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +} { + const decoded = JSON.parse(key) as [EnvironmentIdType, Input]; + return { + environmentId: EnvironmentId.make(decoded[0]), + input: decoded[1], + }; +} + +export function runInEnvironment( + environmentId: EnvironmentIdType, + effect: Effect.Effect, +): Effect.Effect< + A, + E | EnvironmentNotRegisteredError, + EnvironmentRegistry | Exclude +> { + return EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.run(environmentId, effect)), + ); +} + +export function runStreamInEnvironment( + environmentId: EnvironmentIdType, + stream: Stream.Stream, +): Stream.Stream< + A, + E | EnvironmentNotRegisteredError, + EnvironmentRegistry | Exclude +> { + return Stream.unwrap( + EnvironmentRegistry.pipe(Effect.map((registry) => registry.runStream(environmentId, stream))), + ); +} + +export function followStreamInEnvironment( + environmentId: EnvironmentIdType, + stream: Stream.Stream, +): Stream.Stream> { + return Stream.unwrap( + EnvironmentRegistry.pipe( + Effect.map((registry) => registry.followStream(environmentId, stream)), + ), + ); +} + +function createEnvironmentQueryAtomFamily( + runtime: Atom.AtomRuntime, + options: EnvironmentQueryAtomOptions, +): (target: { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +}) => Atom.Atom> { + const rpcGenerationAtom = Atom.family((environmentId: EnvironmentIdType) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.state).pipe( + Stream.filterMap((state) => + state.phase === "connected" ? Result.succeed(state.generation) : Result.failVoid, + ), + Stream.changes, + Stream.map((generation) => generation), + ), + ), + ), + ), + ), + { initialValue: null }, + ), + ); + const family = Atom.family((key: string) => { + const target = parseEnvironmentRpcKey(key); + const idleTtlMs = options.idleTtlMs ?? 5 * 60_000; + const queryAtom = runtime + .atom((get) => { + const generation = Option.getOrNull( + AsyncResult.value(get(rpcGenerationAtom(target.environmentId))), + ); + if (generation === null) { + return Effect.never; + } + return runInEnvironment(target.environmentId, options.execute(target.input)); + }) + .pipe( + Atom.swr({ + staleTime: options.staleTimeMs ?? 30_000, + revalidateOnMount: true, + }), + Atom.setIdleTTL(idleTtlMs), + ); + return ( + options.refreshIntervalMs === undefined + ? queryAtom + : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs)) + ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`${options.label}:${key}`)); + }); + return (target) => family(environmentRpcKey(target)); +} + +export function createEnvironmentSubscriptionAtomFamily( + runtime: Atom.AtomRuntime, + options: EnvironmentSubscriptionAtomOptions, +) { + const family = Atom.family((key: string) => { + const target = parseEnvironmentRpcKey(key); + return runtime + .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input))) + .pipe( + Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000), + Atom.withLabel(`${options.label}:${key}`), + ); + }); + return (target: { readonly environmentId: EnvironmentIdType; readonly input: Input }) => + family(environmentRpcKey(target)); +} + +export function createEnvironmentCommand( + runtime: Atom.AtomRuntime, + options: EnvironmentAtomOptions, +) { + return createRuntimeCommand(runtime, { + label: options.label, + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + execute: (target) => runInEnvironment(target.environmentId, options.execute(target.input)), + }); +} + +function createEnvironmentStreamCommand( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly execute: (input: Input) => Stream.Stream; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentIdType; + readonly input: Input; + }>; + }, +) { + return createRuntimeStreamCommand(runtime, { + label: options.label, + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + execute: (target) => + runStreamInEnvironment(target.environmentId, options.execute(target.input)).pipe( + Stream.withSpan(options.label), + ), + }); +} + +export function createEnvironmentRpcQueryAtomFamily( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly staleTimeMs?: number; + readonly idleTtlMs?: number; + readonly refreshIntervalMs?: number; + }, +) { + return createEnvironmentQueryAtomFamily(runtime, { + label: options.label, + ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), + ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), + ...(options.refreshIntervalMs === undefined + ? {} + : { refreshIntervalMs: options.refreshIntervalMs }), + execute: (input: EnvironmentRpcInput) => request(options.tag, input), + }); +} + +export function createEnvironmentRpcSubscriptionAtomFamily< + R, + ER, + TTag extends EnvironmentSubscriptionRpcTag, + B = EnvironmentRpcStreamValue, +>( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly idleTtlMs?: number; + readonly transform?: ( + stream: Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor | R + >, + ) => Stream.Stream, EnvironmentSupervisor | R>; + }, +) { + return createEnvironmentSubscriptionAtomFamily(runtime, { + label: options.label, + ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), + subscribe: (input: EnvironmentRpcInput) => { + const stream = subscribe(options.tag, input); + return options.transform === undefined + ? (stream as Stream.Stream, EnvironmentSupervisor | R>) + : options.transform(stream); + }, + }); +} + +export function createEnvironmentRpcCommand( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentIdType; + readonly input: EnvironmentRpcInput; + }>; + }, +) { + return createEnvironmentCommand(runtime, { + label: options.label, + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + execute: (input: EnvironmentRpcInput) => request(options.tag, input), + }); +} + +export function createEnvironmentRpcStreamCommand< + R, + ER, + TTag extends EnvironmentStreamCommandRpcTag, +>( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentIdType; + readonly input: EnvironmentRpcInput; + }>; + }, +) { + return createEnvironmentStreamCommand(runtime, { + label: options.label, + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + execute: (input: EnvironmentRpcInput) => runStream(options.tag, input), + }); +} diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts new file mode 100644 index 00000000000..4b9564e031c --- /dev/null +++ b/packages/client-runtime/src/state/server.test.ts @@ -0,0 +1,54 @@ +import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { applyServerConfigProjection, projectServerWelcome } from "./server.ts"; + +const CONFIG = { + availableEditors: [], + issues: [], + keybindings: {}, + keybindingsConfigPath: null, + observability: null, + providers: [], + settings: {}, +} as unknown as ServerConfig; + +describe("server state projection", () => { + it("applies every config category to the projected snapshot", () => { + const snapshot = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: CONFIG, + }); + const settings = { ...CONFIG.settings }; + const projected = applyServerConfigProjection(snapshot, { + version: 1, + type: "settingsUpdated", + payload: { settings }, + }); + + const result = Option.getOrThrow(projected); + expect(result.config.settings).toBe(settings); + expect(result.latestEvent.type).toBe("settingsUpdated"); + }); + + it("retains welcome when a ready event follows in the same stream chunk", () => { + const welcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/repo", + projectName: "repo", + } as ServerLifecycleWelcomePayload; + const [afterWelcome] = projectServerWelcome(Option.none(), { + type: "welcome", + payload: welcome, + }); + const [afterReady, emitted] = projectServerWelcome(afterWelcome, { + type: "ready", + payload: {}, + }); + + expect(Option.getOrThrow(afterReady)).toBe(welcome); + expect(emitted).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts new file mode 100644 index 00000000000..eb784183793 --- /dev/null +++ b/packages/client-runtime/src/state/server.ts @@ -0,0 +1,194 @@ +import { + type EnvironmentId, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleWelcomePayload, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export interface ServerConfigProjection { + readonly config: ServerConfig; + readonly latestEvent: ServerConfigStreamEvent; +} + +export function applyServerConfigProjection( + current: Option.Option, + event: ServerConfigStreamEvent, +): Option.Option { + switch (event.type) { + case "snapshot": + return Option.some({ + config: event.config, + latestEvent: event, + }); + case "keybindingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + keybindings: event.payload.keybindings, + issues: event.payload.issues, + }, + latestEvent: event, + })); + case "providerStatuses": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + providers: event.payload.providers, + }, + latestEvent: event, + })); + case "settingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + settings: event.payload.settings, + }, + latestEvent: event, + })); + } +} + +export function projectServerConfig( + current: Option.Option, + event: ServerConfigStreamEvent, +): readonly [Option.Option, ReadonlyArray] { + const next = applyServerConfigProjection(current, event); + return [next, Option.toArray(next)]; +} + +export function projectServerWelcome( + current: Option.Option, + event: { + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }, +): readonly [ + Option.Option, + ReadonlyArray, +] { + if (event.type !== "welcome") { + return [current, []]; + } + const welcome = event.payload as ServerLifecycleWelcomePayload; + return [Option.some(welcome), [welcome]]; +} + +export function createServerEnvironmentAtoms( + runtime: Atom.AtomRuntime, + options: { + readonly initialConfigValueAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; + }, +) { + const configScheduler = createAtomCommandScheduler(); + const configConcurrency = { + mode: "serial" as const, + key: ({ environmentId }: { readonly environmentId: string }) => environmentId, + }; + const configProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:config-projection", + tag: WS_METHODS.subscribeServerConfig, + transform: (stream) => + stream.pipe(Stream.mapAccum(Option.none, projectServerConfig)), + }); + const emptyConfigAtom = Atom.make(null).pipe( + Atom.withLabel("environment-data:server:config:empty"), + ); + const configValueAtom = Atom.family((environmentId: EnvironmentId | null) => { + if (environmentId === null) { + return emptyConfigAtom; + } + return Atom.make((get): ServerConfig | null => { + const projection = Option.getOrNull( + AsyncResult.value(get(configProjection({ environmentId, input: {} }))), + ); + return projection?.config ?? get(options.initialConfigValueAtom(environmentId)); + }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); + }); + const settingsValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe( + Atom.withLabel(`environment-data:server:settings:${environmentId}`), + ), + ); + const providersValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( + Atom.withLabel(`environment-data:server:providers:${environmentId}`), + ), + ); + + return { + configValueAtom, + settingsValueAtom, + providersValueAtom, + traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:trace-diagnostics", + tag: WS_METHODS.serverGetTraceDiagnostics, + }), + processDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:process-diagnostics", + tag: WS_METHODS.serverGetProcessDiagnostics, + }), + processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:process-resource-history", + tag: WS_METHODS.serverGetProcessResourceHistory, + }), + configProjection, + welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:welcome", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => + stream.pipe( + Stream.mapAccum(Option.none, projectServerWelcome), + ), + }), + refreshProviders: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:refresh-providers", + tag: WS_METHODS.serverRefreshProviders, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), + updateProvider: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:update-provider", + tag: WS_METHODS.serverUpdateProvider, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + upsertKeybinding: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:upsert-keybinding", + tag: WS_METHODS.serverUpsertKeybinding, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + removeKeybinding: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:remove-keybinding", + tag: WS_METHODS.serverRemoveKeybinding, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + updateSettings: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:update-settings", + tag: WS_METHODS.serverUpdateSettings, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + signalProcess: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:signal-process", + tag: WS_METHODS.serverSignalProcess, + }), + }; +} diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts new file mode 100644 index 00000000000..fe1dcdbe3f2 --- /dev/null +++ b/packages/client-runtime/src/state/session.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { initialConfigOption } from "./session.ts"; + +class TestConfigError extends Schema.TaggedErrorClass()("TestConfigError", { + message: Schema.String, +}) {} + +describe("environment session state", () => { + it.effect("turns an initial config failure into an empty value", () => + Effect.gen(function* () { + const result = yield* initialConfigOption( + Effect.fail(new TestConfigError({ message: "temporary failure" })), + ); + expect(Option.isNone(result)).toBe(true); + }), + ); +}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts new file mode 100644 index 00000000000..3cb62009a20 --- /dev/null +++ b/packages/client-runtime/src/state/session.ts @@ -0,0 +1,95 @@ +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export function initialConfigOption( + initialConfig: Effect.Effect, +): Effect.Effect> { + return initialConfig.pipe( + Effect.map(Option.some), + Effect.catch((error) => + Effect.logWarning("Could not load the initial environment configuration.").pipe( + Effect.annotateLogs({ ...safeErrorLogAttributes(error) }), + Effect.as(Option.none()), + ), + ), + ); +} + +export function createEnvironmentSessionAtoms( + runtime: Atom.AtomRuntime, +) { + const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.mapEffect( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (session) => initialConfigOption(session.initialConfig), + }), + ), + ), + ), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + // This is only the bootstrap config captured when a transport session is + // established. Consumers that need current provider/settings state must use + // createServerEnvironmentAtoms(...).configValueAtom instead. + const initialConfigValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ServerConfig | null => + Option.getOrNull( + Option.getOrElse(AsyncResult.value(get(initialConfigAtom(environmentId))), () => + Option.none(), + ), + ), + ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)), + ); + + const preparedConnectionAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.prepared)), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () => + Option.none(), + ), + ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), + ); + + return { + initialConfigAtom, + initialConfigValueAtom, + preparedConnectionAtom, + preparedConnectionValueAtom, + }; +} diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts new file mode 100644 index 00000000000..2eab7214225 --- /dev/null +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -0,0 +1,120 @@ +import { + EnvironmentId, + ORCHESTRATION_WS_METHODS, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { makeEnvironmentShellState } from "./shell.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", +}; + +function session(client: WsRpcProtocolClient): RpcSession.RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +describe("environment shell synchronization", () => { + it.effect("publishes live state before persistence and preserves it when ready", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.never, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + clear: () => Effect.void, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + }); + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: LIVE_SHELL_SNAPSHOT, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "live"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + + const state = yield* SubscriptionRef.get(shellState); + expect(state.status).toBe("live"); + expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); + }), + ); +}); diff --git a/packages/client-runtime/src/state/shell.test.ts b/packages/client-runtime/src/state/shell.test.ts new file mode 100644 index 00000000000..f1326e0a5cb --- /dev/null +++ b/packages/client-runtime/src/state/shell.test.ts @@ -0,0 +1,130 @@ +import type { ServerConfig } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import type { EnvironmentShellState } from "./shell.ts"; +import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); + +function environmentEntry(environmentId: EnvironmentId, label: string) { + return { + target: new PrimaryConnectionTarget({ + environmentId, + label, + httpBaseUrl: `https://${environmentId}.example.test`, + wsBaseUrl: `wss://${environmentId}.example.test`, + }), + profile: Option.none(), + }; +} + +function shellState(input: { + readonly status: EnvironmentShellState["status"]; + readonly updatedAt?: string; + readonly error?: string; + readonly snapshotSequence?: number; +}): EnvironmentShellState { + return { + snapshot: + input.updatedAt === undefined + ? Option.none() + : Option.some({ + snapshotSequence: input.snapshotSequence ?? 1, + updatedAt: input.updatedAt, + projects: [], + threads: [], + }), + status: input.status, + error: input.error === undefined ? Option.none() : Option.some(input.error), + }; +} + +function makeHarness() { + const shellStateAtoms = Atom.family((environmentId: EnvironmentId) => + Atom.make( + environmentId === ENVIRONMENT_ID + ? shellState({ + status: "cached", + updatedAt: "2026-06-01T00:00:00.000Z", + }) + : shellState({ + status: "synchronizing", + updatedAt: "2026-06-02T00:00:00.000Z", + error: "Retrying.", + }), + ), + ); + const configAtoms = Atom.family((_environmentId: EnvironmentId) => + Atom.make(null), + ); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map([ + [ENVIRONMENT_ID, environmentEntry(ENVIRONMENT_ID, "Environment")], + [OTHER_ENVIRONMENT_ID, environmentEntry(OTHER_ENVIRONMENT_ID, "Other environment")], + ]), + }); + const summaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom, + shellStateValueAtom: shellStateAtoms, + }); + const serverConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom, + serverConfigValueAtom: configAtoms, + }); + + return { + registry: AtomRegistry.make(), + shellStateAtom: shellStateAtoms, + configAtom: configAtoms, + summaryAtom, + serverConfigsAtom, + }; +} + +describe("environment shell projections", () => { + it("summarizes shell state and preserves identity when only irrelevant snapshot data changes", () => { + const harness = makeHarness(); + const summary = harness.registry.get(harness.summaryAtom); + + expect(summary).toEqual({ + hasSnapshot: true, + hasSynchronizingShell: true, + hasCachedShell: true, + hasLiveShell: false, + firstError: "Retrying.", + latestSnapshotUpdatedAt: "2026-06-02T00:00:00.000Z", + }); + + harness.registry.set( + harness.shellStateAtom(ENVIRONMENT_ID), + shellState({ + status: "cached", + updatedAt: "2026-06-01T00:00:00.000Z", + snapshotSequence: 2, + }), + ); + + expect(harness.registry.get(harness.summaryAtom)).toBe(summary); + }); + + it("preserves server-config map identity until a config reference changes", () => { + const harness = makeHarness(); + const empty = harness.registry.get(harness.serverConfigsAtom); + const config = { cwd: "/repo" } as ServerConfig; + + harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); + const withConfig = harness.registry.get(harness.serverConfigsAtom); + + expect(withConfig).not.toBe(empty); + expect(withConfig.get(ENVIRONMENT_ID)).toBe(config); + + harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); + expect(harness.registry.get(harness.serverConfigsAtom)).toBe(withConfig); + }); +}); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts new file mode 100644 index 00000000000..2b0ba6346f5 --- /dev/null +++ b/packages/client-runtime/src/state/shell.ts @@ -0,0 +1,319 @@ +import { + ORCHESTRATION_WS_METHODS, + type EnvironmentId, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, + type ServerConfig, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { connectionProjectionPhase } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { subscribe } from "../rpc/client.ts"; +import { applyShellStreamEvent } from "./shellReducer.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; + +export interface EnvironmentShellState { + readonly snapshot: Option.Option; + readonly status: EnvironmentShellStatus; + readonly error: Option.Option; +} + +const EMPTY_SHELL_STATE: EnvironmentShellState = { + snapshot: Option.none(), + status: "empty", + error: Option.none(), +}; + +function shellStatusForSnapshot( + snapshot: Option.Option, +): EnvironmentShellStatus { + return Option.isSome(snapshot) ? "cached" : "empty"; +} + +const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data."; + +export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + const environmentId = supervisor.target.environmentId; + const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached environment shell.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + Effect.as(Option.none()), + ), + ), + ); + const state = yield* SubscriptionRef.make({ + snapshot: cachedSnapshot, + status: shellStatusForSnapshot(cachedSnapshot), + error: Option.none(), + }); + const persistence = yield* Queue.sliding(1); + + const persist = Effect.fn("EnvironmentShellState.persist")(function* ( + snapshot: OrchestrationShellSnapshot, + ) { + yield* cache.saveShell(environmentId, snapshot).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist environment shell cache.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + ), + ), + ); + }); + + yield* Stream.fromQueue(persistence).pipe( + Stream.debounce("500 millis"), + Stream.runForEach(persist), + Effect.forkScoped, + ); + + const setDisconnected = SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + })); + const setSynchronizing = SubscriptionRef.update(state, (current) => ({ + ...current, + status: "synchronizing" as const, + error: Option.none(), + })); + const setReady = SubscriptionRef.update(state, (current) => + current.status === "live" + ? current + : { + ...current, + status: "synchronizing" as const, + error: Option.none(), + }, + ); + const setStreamError = (error: unknown) => + Effect.logWarning("Could not synchronize the environment shell.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + Effect.andThen( + SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + error: Option.some(SHELL_SYNCHRONIZATION_ERROR_MESSAGE), + })), + ), + ); + + const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( + item: OrchestrationShellStreamItem, + ) { + const current = yield* SubscriptionRef.get(state); + const nextSnapshot = + item.kind === "snapshot" + ? item.snapshot + : Option.match(current.snapshot, { + onNone: () => null, + onSome: (snapshot) => + item.sequence > snapshot.snapshotSequence + ? applyShellStreamEvent(snapshot, item) + : snapshot, + }); + if (nextSnapshot === null) { + return; + } + + yield* SubscriptionRef.set(state, { + snapshot: Option.some(nextSnapshot), + status: "live", + error: Option.none(), + }); + yield* Queue.offer(persistence, nextSnapshot); + }); + + yield* subscribe( + ORCHESTRATION_WS_METHODS.subscribeShell, + {}, + { + onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), + }, + ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); + yield* SubscriptionRef.changes(supervisor.state).pipe( + Stream.runForEach((connectionState) => { + switch (connectionProjectionPhase(connectionState)) { + case "synchronizing": + return setSynchronizing; + case "disconnected": + return setDisconnected; + case "ready": + return setReady; + } + }), + Effect.forkScoped, + ); + + return state; +}); + +export function shellStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), + ); +} + +export interface EnvironmentShellSummary { + readonly hasSnapshot: boolean; + readonly hasSynchronizingShell: boolean; + readonly hasCachedShell: boolean; + readonly hasLiveShell: boolean; + readonly firstError: string | null; + readonly latestSnapshotUpdatedAt: string | null; +} + +const EMPTY_ENVIRONMENT_SHELL_SUMMARY: EnvironmentShellSummary = Object.freeze({ + hasSnapshot: false, + hasSynchronizingShell: false, + hasCachedShell: false, + hasLiveShell: false, + firstError: null, + latestSnapshotUpdatedAt: null, +}); + +const EMPTY_SERVER_CONFIGS: ReadonlyMap = new Map(); + +function shellSummariesEqual( + left: EnvironmentShellSummary, + right: EnvironmentShellSummary, +): boolean { + return ( + left.hasSnapshot === right.hasSnapshot && + left.hasSynchronizingShell === right.hasSynchronizingShell && + left.hasCachedShell === right.hasCachedShell && + left.hasLiveShell === right.hasLiveShell && + left.firstError === right.firstError && + left.latestSnapshotUpdatedAt === right.latestSnapshotUpdatedAt + ); +} + +function mapsEqual(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +export function createEnvironmentShellSummaryAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly shellStateValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + let previousSummary = EMPTY_ENVIRONMENT_SHELL_SUMMARY; + return Atom.make((get) => { + let hasSnapshot = false; + let hasSynchronizingShell = false; + let hasCachedShell = false; + let hasLiveShell = false; + let firstError: string | null = null; + let latestSnapshotUpdatedAt: string | null = null; + + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const state = get(input.shellStateValueAtom(environmentId)); + hasSynchronizingShell ||= state.status === "synchronizing"; + hasCachedShell ||= state.status === "cached"; + hasLiveShell ||= state.status === "live"; + if (firstError === null) { + firstError = Option.getOrNull(state.error); + } + if (Option.isNone(state.snapshot)) { + continue; + } + hasSnapshot = true; + const updatedAt = state.snapshot.value.updatedAt; + if (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) { + latestSnapshotUpdatedAt = updatedAt; + } + } + + const next: EnvironmentShellSummary = { + hasSnapshot, + hasSynchronizingShell, + hasCachedShell, + hasLiveShell, + firstError, + latestSnapshotUpdatedAt, + }; + if (shellSummariesEqual(previousSummary, next)) { + return previousSummary; + } + previousSummary = next; + return previousSummary; + }).pipe(Atom.withLabel("environment-shell-summary")); +} + +export function createEnvironmentServerConfigsAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + let previousServerConfigs = EMPTY_SERVER_CONFIGS; + return Atom.make((get) => { + const next = new Map(); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const config = get(input.serverConfigValueAtom(environmentId)); + if (config !== null) { + next.set(environmentId, config); + } + } + if (mapsEqual(previousServerConfigs, next)) { + return previousServerConfigs; + } + previousServerConfigs = next; + return previousServerConfigs; + }).pipe(Atom.withLabel("environment-server-configs")); +} + +export function createEnvironmentShellAtoms( + runtime: Atom.AtomRuntime, +) { + const stateAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom(shellStateChanges(environmentId), { + initialValue: EMPTY_SHELL_STATE, + }), + ); + + const stateValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(stateAtom(environmentId))), () => EMPTY_SHELL_STATE), + ).pipe(Atom.withLabel(`environment-shell-state-value:${environmentId}`)), + ); + + return { + stateAtom, + stateValueAtom, + }; +} + +export * from "./models.ts"; +export * from "./shellCommands.ts"; +export * from "./shellReducer.ts"; +export * from "./snapshots.ts"; diff --git a/packages/client-runtime/src/state/shellCommands.ts b/packages/client-runtime/src/state/shellCommands.ts new file mode 100644 index 00000000000..785bb83ed47 --- /dev/null +++ b/packages/client-runtime/src/state/shellCommands.ts @@ -0,0 +1,16 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcCommand } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createShellEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + openInEditor: createEnvironmentRpcCommand(runtime, { + label: "environment-data:shell:open-in-editor", + tag: WS_METHODS.shellOpenInEditor, + }), + }; +} diff --git a/packages/client-runtime/src/shellSnapshotReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts similarity index 87% rename from packages/client-runtime/src/shellSnapshotReducer.test.ts rename to packages/client-runtime/src/state/shellReducer.test.ts index e1cc19aa6ca..57229fbad49 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; -import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; +import { applyShellStreamEvent } from "./shellReducer.ts"; const baseSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 0, @@ -45,6 +45,26 @@ const stubThread = { } as const; describe("applyShellStreamEvent", () => { + it("ignores stale project upserts without mutating the snapshot", () => { + const snapshotWithProject: OrchestrationShellSnapshot = { + ...baseSnapshot, + snapshotSequence: 4, + projects: [stubProject], + }; + + for (const sequence of [3, 4]) { + const next = applyShellStreamEvent(snapshotWithProject, { + kind: "project-upserted", + sequence, + project: { ...stubProject, title: "Stale Title" }, + }); + + expect(next).toBe(snapshotWithProject); + expect(next.snapshotSequence).toBe(4); + expect(next.projects[0]?.title).toBe("Test Project"); + } + }); + describe("project-upserted", () => { it("adds a new project", () => { const event: OrchestrationShellStreamEvent = { diff --git a/packages/client-runtime/src/shellSnapshotReducer.ts b/packages/client-runtime/src/state/shellReducer.ts similarity index 91% rename from packages/client-runtime/src/shellSnapshotReducer.ts rename to packages/client-runtime/src/state/shellReducer.ts index a30eedb769b..3d3b22a1289 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -2,7 +2,7 @@ import * as Arr from "effect/Array"; import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; /** - * Apply a single shell stream event to an existing snapshot, returning a new + * Reduce a single shell stream event into an existing snapshot, returning a new * snapshot with the event's changes applied. This is a pure reducer that both * web and mobile can use to keep their local shell snapshot in sync. * @@ -13,6 +13,8 @@ export function applyShellStreamEvent( snapshot: OrchestrationShellSnapshot, event: OrchestrationShellStreamEvent, ): OrchestrationShellSnapshot { + if (event.sequence <= snapshot.snapshotSequence) return snapshot; + switch (event.kind) { case "project-upserted": { const projects = snapshot.projects.some((p) => p.id === event.project.id) diff --git a/packages/client-runtime/src/state/snapshots.ts b/packages/client-runtime/src/state/snapshots.ts new file mode 100644 index 00000000000..0000dcb12ce --- /dev/null +++ b/packages/client-runtime/src/state/snapshots.ts @@ -0,0 +1,20 @@ +import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentShellState } from "./shell.ts"; + +export function createEnvironmentSnapshotAtom( + shellStateAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>, +) { + return Atom.family((environmentId: EnvironmentId) => + Atom.make((get): OrchestrationShellSnapshot | null => + Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), { + onNone: () => null, + onSome: (state) => Option.getOrNull(state.snapshot), + }), + ).pipe(Atom.withLabel(`environment-snapshot:${environmentId}`)), + ); +} diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts new file mode 100644 index 00000000000..5bff2fa77e2 --- /dev/null +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -0,0 +1,41 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; + +export function createSourceControlEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const commandScheduler = createAtomCommandScheduler(); + return { + discovery: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:source-control-discovery", + tag: WS_METHODS.serverDiscoverSourceControl, + }), + repository: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:source-control:repository", + tag: WS_METHODS.sourceControlLookupRepository, + }), + cloneRepository: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:clone-repository", + tag: WS_METHODS.sourceControlCloneRepository, + scheduler: commandScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId }) => environmentId, + }, + }), + publishRepository: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:publish-repository", + tag: WS_METHODS.sourceControlPublishRepository, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + }; +} diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts new file mode 100644 index 00000000000..028f7a8c660 --- /dev/null +++ b/packages/client-runtime/src/state/terminal.ts @@ -0,0 +1,95 @@ +import { type TerminalSummary, WS_METHODS } from "@t3tools/contracts"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { + applyTerminalAttachStreamEvent, + applyTerminalMetadataStreamEvent, + EMPTY_TERMINAL_BUFFER_STATE, +} from "./terminalSession.ts"; + +export function createTerminalEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const lifecycleScheduler = createAtomCommandScheduler(); + const resizeScheduler = createAtomCommandScheduler(); + const terminalThreadKey = ({ + environmentId, + input, + }: { + readonly environmentId: string; + readonly input: { readonly threadId: string; readonly terminalId?: string | undefined }; + }) => JSON.stringify([environmentId, input.threadId]); + const terminalSessionKey = ({ + environmentId, + input, + }: { + readonly environmentId: string; + readonly input: { readonly threadId: string; readonly terminalId?: string | undefined }; + }) => JSON.stringify([environmentId, input.threadId, input.terminalId ?? null]); + const lifecycleConcurrency = { mode: "serial" as const, key: terminalThreadKey }; + return { + attach: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:attach", + subscribe: (input: EnvironmentRpcInput) => + subscribe(WS_METHODS.terminalAttach, input).pipe( + Stream.scan(EMPTY_TERMINAL_BUFFER_STATE, applyTerminalAttachStreamEvent), + ), + }), + events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:events", + tag: WS_METHODS.subscribeTerminalEvents, + }), + metadata: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:metadata", + subscribe: (_input: null) => + subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( + Stream.scan([] as ReadonlyArray, applyTerminalMetadataStreamEvent), + ), + }), + open: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:open", + tag: WS_METHODS.terminalOpen, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + write: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:write", + tag: WS_METHODS.terminalWrite, + }), + resize: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:resize", + tag: WS_METHODS.terminalResize, + scheduler: resizeScheduler, + concurrency: { mode: "latest", key: terminalSessionKey }, + }), + clear: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:clear", + tag: WS_METHODS.terminalClear, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + restart: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:restart", + tag: WS_METHODS.terminalRestart, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + close: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:close", + tag: WS_METHODS.terminalClose, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + }; +} + +export * from "./terminalSession.ts"; diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts new file mode 100644 index 00000000000..85c57592d11 --- /dev/null +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; + +import { + applyTerminalAttachStreamEvent, + applyTerminalMetadataStreamEvent, + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, + selectRunningSubprocessTerminalIds, +} from "./terminalSession.ts"; + +const TARGET = { + environmentId: EnvironmentId.make("env-local"), + threadId: ThreadId.make("thread-1"), + terminalId: "term-1", +} as const; + +const BASE_SNAPSHOT: TerminalSessionSnapshot = { + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + history: "hello", + exitCode: null, + exitSignal: null, + label: "Terminal 1", + updatedAt: "2026-04-01T00:00:00.000Z", +}; + +describe("terminal session reducers", () => { + it("prefers live attach status over stale metadata after the attach stream starts", () => { + const summary = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: "running", + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + })[0]!; + const attached = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "error", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + message: "Terminal disconnected.", + }); + + expect(combineTerminalSessionState(summary, attached)).toMatchObject({ + status: "error", + error: "Terminal disconnected.", + version: 1, + }); + }); + + it("uses metadata status before an attach stream has emitted", () => { + const summary = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: "running", + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + })[0]!; + + expect(combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE).status).toBe( + "running", + ); + }); + + it("does not treat an idle running shell as a running subprocess", () => { + const idleSession = { + target: TARGET, + state: { + ...combineTerminalSessionState(null, EMPTY_TERMINAL_BUFFER_STATE), + status: "running" as const, + hasRunningSubprocess: false, + }, + }; + const activeSession = { + target: { ...TARGET, terminalId: "term-2" }, + state: { + ...idleSession.state, + hasRunningSubprocess: true, + }, + }; + + expect(selectRunningSubprocessTerminalIds([idleSession, activeSession])).toEqual(["term-2"]); + }); + + it("reduces attach snapshots and output without an imperative session manager", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const output = applyTerminalAttachStreamEvent( + snapshot, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: " world", + }, + 8, + ); + + expect(output).toMatchObject({ + buffer: "lo world", + status: "running", + error: null, + version: 2, + }); + }); + + it("reduces terminal metadata snapshots, upserts, and removals", () => { + const initial = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: BASE_SNAPSHOT.status, + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + }); + const updated = applyTerminalMetadataStreamEvent(initial, { + type: "upsert", + terminal: { + ...initial[0]!, + hasRunningSubprocess: true, + }, + }); + const removed = applyTerminalMetadataStreamEvent(updated, { + type: "remove", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + + expect(updated).toHaveLength(1); + expect(updated[0]?.hasRunningSubprocess).toBe(true); + expect(removed).toEqual([]); + }); + + it("caps retained output by UTF-8 byte length", () => { + const state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "🙂🙂", + }, + 4, + ); + + expect(state.buffer).toBe("🙂"); + }); +}); diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts new file mode 100644 index 00000000000..ee444e36db4 --- /dev/null +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -0,0 +1,194 @@ +import type { + EnvironmentId, + TerminalAttachStreamEvent, + TerminalMetadataStreamEvent, + TerminalSessionSnapshot, + TerminalSummary, + ThreadId, +} from "@t3tools/contracts"; + +export interface TerminalSessionState { + readonly summary: TerminalSummary | null; + readonly buffer: string; + readonly status: TerminalSessionSnapshot["status"] | "closed"; + readonly error: string | null; + readonly hasRunningSubprocess: boolean; + readonly updatedAt: string | null; + readonly version: number; +} + +export interface TerminalBufferState { + readonly buffer: string; + readonly status: TerminalSessionSnapshot["status"] | "closed"; + readonly error: string | null; + readonly updatedAt: string | null; + readonly version: number; +} + +export interface KnownTerminalSessionTarget { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly terminalId: string; +} + +export interface KnownTerminalSession { + readonly target: KnownTerminalSessionTarget; + readonly state: TerminalSessionState; +} + +export function selectRunningSubprocessTerminalIds( + sessions: ReadonlyArray, +): ReadonlyArray { + return sessions + .filter((session) => session.state.hasRunningSubprocess) + .map((session) => session.target.terminalId); +} + +export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ + buffer: "", + status: "closed", + error: null, + updatedAt: null, + version: 0, +}); + +export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ + summary: null, + buffer: "", + status: "closed", + error: null, + hasRunningSubprocess: false, + updatedAt: null, + version: 0, +}); + +export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { + if (maxBufferBytes <= 0) { + return ""; + } + + const encoded = textEncoder.encode(buffer); + if (encoded.byteLength <= maxBufferBytes) { + return buffer; + } + + let start = encoded.byteLength - maxBufferBytes; + while (start < encoded.length) { + const byte = encoded[start]; + if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { + break; + } + start += 1; + } + + return textDecoder.decode(encoded.subarray(start)); +} + +export function terminalBufferStateFromSnapshot( + snapshot: TerminalSessionSnapshot, + maxBufferBytes: number, +): TerminalBufferState { + return { + buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), + status: snapshot.status, + error: null, + updatedAt: snapshot.updatedAt, + version: 1, + }; +} + +function latestTimestamp(left: string | null, right: string | null): string | null { + if (left === null) return right; + if (right === null) return left; + return Date.parse(left) >= Date.parse(right) ? left : right; +} + +export function combineTerminalSessionState( + summary: TerminalSummary | null, + buffer: TerminalBufferState, +): TerminalSessionState { + return { + summary, + buffer: buffer.buffer, + status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), + error: buffer.error, + hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, + updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), + version: buffer.version, + }; +} + +export function applyTerminalAttachStreamEvent( + current: TerminalBufferState, + event: TerminalAttachStreamEvent, + maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES, +): TerminalBufferState { + switch (event.type) { + case "snapshot": + case "restarted": + return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); + case "output": + return { + ...current, + buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), + status: current.status === "closed" ? "running" : current.status, + error: null, + version: current.version + 1, + }; + case "cleared": + return { + ...current, + buffer: "", + error: null, + version: current.version + 1, + }; + case "exited": + return { + ...current, + status: "exited", + error: null, + version: current.version + 1, + }; + case "closed": + return { + ...current, + status: "closed", + error: null, + version: current.version + 1, + }; + case "error": + return { + ...current, + status: "error", + error: event.message, + version: current.version + 1, + }; + case "activity": + return current; + } +} + +export function applyTerminalMetadataStreamEvent( + current: ReadonlyArray, + event: TerminalMetadataStreamEvent, +): ReadonlyArray { + if (event.type === "snapshot") { + return event.terminals; + } + if (event.type === "remove") { + return current.filter( + (terminal) => + terminal.threadId !== event.threadId || terminal.terminalId !== event.terminalId, + ); + } + const next = current.filter( + (terminal) => + terminal.threadId !== event.terminal.threadId || + terminal.terminalId !== event.terminal.terminalId, + ); + return [...next, event.terminal]; +} diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts new file mode 100644 index 00000000000..c271cea590e --- /dev/null +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -0,0 +1,149 @@ +import * as Crypto from "effect/Crypto"; +import { Atom } from "effect/unstable/reactivity"; + +import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts"; +import { + type ArchiveThreadInput, + type CreateThreadInput, + type DeleteThreadInput, + type InterruptThreadTurnInput, + type RespondToThreadApprovalInput, + type RespondToThreadUserInputInput, + type RevertThreadCheckpointInput, + type RequestThreadGoalInput, + type SetThreadInteractionModeInput, + type SetThreadRuntimeModeInput, + type StartThreadTurnInput, + type StopThreadSessionInput, + type UnarchiveThreadInput, + type UpdateThreadMetadataInput, + archiveThread, + createThread, + deleteThread, + interruptThreadTurn, + respondToThreadApproval, + respondToThreadUserInput, + requestThreadGoal, + revertThreadCheckpoint, + setThreadInteractionMode, + setThreadRuntimeMode, + startThreadTurn, + stopThreadSession, + unarchiveThread, + updateThreadMetadata, +} from "../operations/commands.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export type { + ArchiveThreadInput, + CreateThreadInput, + DeleteThreadInput, + InterruptThreadTurnInput, + RespondToThreadApprovalInput, + RespondToThreadUserInputInput, + RevertThreadCheckpointInput, + RequestThreadGoalInput, + SetThreadInteractionModeInput, + SetThreadRuntimeModeInput, + StartThreadTurnInput, + StopThreadSessionInput, + UnarchiveThreadInput, + UpdateThreadMetadataInput, +} from "../operations/commands.ts"; + +export function createThreadEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const scheduler = createAtomCommandScheduler(); + const concurrency = { + mode: "serial" as const, + key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => + JSON.stringify([environmentId, input.threadId]), + }; + return { + create: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:create", + execute: (input: CreateThreadInput) => createThread(input), + scheduler, + concurrency, + }), + delete: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:delete", + execute: (input: DeleteThreadInput) => deleteThread(input), + scheduler, + concurrency, + }), + archive: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:archive", + execute: (input: ArchiveThreadInput) => archiveThread(input), + scheduler, + concurrency, + }), + unarchive: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unarchive", + execute: (input: UnarchiveThreadInput) => unarchiveThread(input), + scheduler, + concurrency, + }), + updateMetadata: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:update-metadata", + execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), + scheduler, + concurrency, + }), + setRuntimeMode: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-runtime-mode", + execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), + scheduler, + concurrency, + }), + setInteractionMode: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-interaction-mode", + execute: (input: SetThreadInteractionModeInput) => setThreadInteractionMode(input), + scheduler, + concurrency, + }), + startTurn: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:start-turn", + execute: (input: StartThreadTurnInput) => startThreadTurn(input), + scheduler, + concurrency, + }), + interruptTurn: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:interrupt-turn", + execute: (input: InterruptThreadTurnInput) => interruptThreadTurn(input), + scheduler, + concurrency, + }), + respondToApproval: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:respond-to-approval", + execute: (input: RespondToThreadApprovalInput) => respondToThreadApproval(input), + scheduler, + concurrency, + }), + respondToUserInput: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:respond-to-user-input", + execute: (input: RespondToThreadUserInputInput) => respondToThreadUserInput(input), + scheduler, + concurrency, + }), + revertCheckpoint: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:revert-checkpoint", + execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input), + scheduler, + concurrency, + }), + stopSession: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:stop-session", + execute: (input: StopThreadSessionInput) => stopThreadSession(input), + scheduler, + concurrency, + }), + requestGoal: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:request-goal", + execute: (input: RequestThreadGoalInput) => requestThreadGoal(input), + scheduler, + concurrency, + }), + }; +} diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts new file mode 100644 index 00000000000..f048573c2ef --- /dev/null +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -0,0 +1,185 @@ +import type { + OrchestrationCheckpointSummary, + OrchestrationLatestTurn, + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, + OrchestrationThread, + OrchestrationThreadActivity, + ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; +import { scopeThread } from "./models.ts"; +import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; +import { parseThreadKey, threadKey } from "./entities.ts"; +import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; + +const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); +const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); +const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); +const EMPTY_CHECKPOINTS: ReadonlyArray = Object.freeze([]); + +/** + * Combine detail-only collections with the shell's authoritative thread metadata. + * + * Shell and detail subscriptions are intentionally independent. A cached detail can + * therefore briefly outlive a newer shell snapshot after reconnecting. Workspace + * consumers must use the shell branch/worktree/project fields so they do not target + * a stale checkout while retaining messages, activities, plans, and checkpoints + * from the detail subscription. + */ +export function mergeEnvironmentThread( + detail: EnvironmentThread | null, + shell: EnvironmentThreadShell | null, +): EnvironmentThread | null { + if (detail === null || shell === null) { + return detail; + } + if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) { + return detail; + } + + return { + ...detail, + environmentId: shell.environmentId, + id: shell.id, + projectId: shell.projectId, + title: shell.title, + modelSelection: shell.modelSelection, + runtimeMode: shell.runtimeMode, + interactionMode: shell.interactionMode, + branch: shell.branch, + worktreePath: shell.worktreePath, + latestTurn: shell.latestTurn, + createdAt: shell.createdAt, + updatedAt: shell.updatedAt, + archivedAt: shell.archivedAt, + session: shell.session, + }; +} + +export function createEnvironmentThreadDetailAtoms( + threadStateAtom: ( + environmentId: ScopedThreadRef["environmentId"], + threadId: ScopedThreadRef["threadId"], + ) => Atom.Atom>, +) { + const threadStateValueAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + return Atom.make((get) => + Option.getOrElse( + AsyncResult.value(get(threadStateAtom(ref.environmentId, ref.threadId))), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ), + ).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-state-value:${key}`), + ); + }); + + const threadDetailAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previousSource: OrchestrationThread | null = null; + let previousValue: EnvironmentThread | null = null; + return Atom.make((get) => { + const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeThread(ref.environmentId, source); + return previousValue; + }).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-detail:${key}`), + ); + }); + + const threadStatusAtomFamily = Atom.family((key: string) => + Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-status:${key}`), + ), + ); + + const threadErrorAtomFamily = Atom.family((key: string) => + Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-error:${key}`), + ), + ); + + const threadMessagesAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES, + ).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-messages:${key}`), + ), + ); + + const threadActivitiesAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES, + ).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-activities:${key}`), + ), + ); + + const threadProposedPlansAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS, + ).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-proposed-plans:${key}`), + ), + ); + + const threadCheckpointsAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS, + ).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-checkpoints:${key}`), + ), + ); + + const threadSessionAtomFamily = Atom.family((key: string) => + Atom.make( + (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null, + ).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-session:${key}`), + ), + ); + + const threadLatestTurnAtomFamily = Atom.family((key: string) => + Atom.make( + (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null, + ).pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-latest-turn:${key}`), + ), + ); + + return { + stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)), + detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)), + statusAtom: (ref: ScopedThreadRef) => threadStatusAtomFamily(threadKey(ref)), + errorAtom: (ref: ScopedThreadRef) => threadErrorAtomFamily(threadKey(ref)), + messagesAtom: (ref: ScopedThreadRef) => threadMessagesAtomFamily(threadKey(ref)), + activitiesAtom: (ref: ScopedThreadRef) => threadActivitiesAtomFamily(threadKey(ref)), + proposedPlansAtom: (ref: ScopedThreadRef) => threadProposedPlansAtomFamily(threadKey(ref)), + checkpointsAtom: (ref: ScopedThreadRef) => threadCheckpointsAtomFamily(threadKey(ref)), + sessionAtom: (ref: ScopedThreadRef) => threadSessionAtomFamily(threadKey(ref)), + latestTurnAtom: (ref: ScopedThreadRef) => threadLatestTurnAtomFamily(threadKey(ref)), + }; +} diff --git a/packages/client-runtime/src/threadDetailReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts similarity index 93% rename from packages/client-runtime/src/threadDetailReducer.test.ts rename to packages/client-runtime/src/state/threadReducer.test.ts index 7dcba1f6245..55fdf76996b 100644 --- a/packages/client-runtime/src/threadDetailReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -11,7 +11,7 @@ import { } from "@t3tools/contracts"; import type { OrchestrationThread } from "@t3tools/contracts"; -import { applyThreadDetailEvent } from "./threadDetailReducer.ts"; +import { applyThreadDetailEvent } from "./threadReducer.ts"; const baseEventFields = { eventId: EventId.make("event-1"), @@ -524,6 +524,49 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.activities[0]?.kind).toBe("file-edit"); } }); + + it("preserves the complete activity history when live events arrive", () => { + const existingActivities = Array.from({ length: 129 }, (_, index) => ({ + id: EventId.make(`activity-${index}`), + tone: "tool" as const, + kind: "command", + summary: `Ran command ${index}`, + payload: {}, + turnId: TurnId.make("turn-1"), + sequence: index, + createdAt: "2026-04-01T11:00:00.000Z", + })); + const result = applyThreadDetailEvent( + { ...baseThread, activities: existingActivities }, + { + ...baseEventFields, + sequence: 130, + occurredAt: "2026-04-01T11:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-129"), + tone: "tool", + kind: "command", + summary: "Ran command 129", + payload: {}, + turnId: TurnId.make("turn-1"), + sequence: 129, + createdAt: "2026-04-01T11:01:00.000Z", + }, + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activities).toHaveLength(130); + expect(result.thread.activities[0]?.id).toBe("activity-0"); + } + }); }); describe("thread.turn-diff-completed", () => { diff --git a/packages/client-runtime/src/threadDetailReducer.ts b/packages/client-runtime/src/state/threadReducer.ts similarity index 95% rename from packages/client-runtime/src/threadDetailReducer.ts rename to packages/client-runtime/src/state/threadReducer.ts index bcf23a02fcf..665fcb5018d 100644 --- a/packages/client-runtime/src/threadDetailReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -13,24 +13,6 @@ import type { TurnId, } from "@t3tools/contracts"; -/** - * Retention limits for collections within a thread. - * These prevent unbounded growth of in-memory thread state. - */ -export interface ThreadDetailRetentionLimits { - readonly maxMessages: number; - readonly maxProposedPlans: number; - readonly maxCheckpoints: number; - readonly maxActivities: number; -} - -export const DEFAULT_THREAD_DETAIL_LIMITS: ThreadDetailRetentionLimits = { - maxMessages: 512, - maxProposedPlans: 64, - maxCheckpoints: 256, - maxActivities: 128, -}; - export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } | { readonly kind: "deleted" } @@ -65,7 +47,6 @@ const activityOrder = O.combineAll([ export function applyThreadDetailEvent( thread: OrchestrationThread, event: OrchestrationEvent, - limits: ThreadDetailRetentionLimits = DEFAULT_THREAD_DETAIL_LIMITS, ): ThreadDetailReducerResult { switch (event.type) { // ── Project events (irrelevant to thread detail) ──────────────── @@ -232,8 +213,6 @@ export function applyThreadDetailEvent( }, ) : Arr.append(thread.messages, message); - const cappedMessages = Arr.takeRight(messages, limits.maxMessages); - // Update latestTurn for assistant messages bound to a turn. A completed // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn @@ -288,7 +267,7 @@ export function applyThreadDetailEvent( kind: "updated", thread: { ...thread, - messages: cappedMessages, + messages, checkpoints, latestTurn, updatedAt: event.occurredAt, @@ -390,7 +369,6 @@ export function applyThreadDetailEvent( Arr.filter((entry) => entry.id !== proposedPlan.id), Arr.append(proposedPlan), Arr.sort(proposedPlanOrder), - Arr.takeRight(limits.maxProposedPlans), ); return { @@ -422,7 +400,6 @@ export function applyThreadDetailEvent( Arr.filter((entry) => entry.turnId !== checkpoint.turnId), Arr.append(checkpoint), Arr.sort(checkpointOrder), - Arr.takeRight(limits.maxCheckpoints), ); // Mid-turn diff updates produce placeholder checkpoints; record the @@ -459,18 +436,13 @@ export function applyThreadDetailEvent( entry.checkpointTurnCount <= event.payload.turnCount, ), Arr.sort(checkpointOrder), - Arr.takeRight(limits.maxCheckpoints), ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); - const messages = pipe( - retainMessagesAfterRevert(thread.messages, retainedTurnIds), - Arr.takeRight(limits.maxMessages), - ); + const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); const proposedPlans = pipe( thread.proposedPlans, Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), - Arr.takeRight(limits.maxProposedPlans), ); const activities = pipe( thread.activities, @@ -511,7 +483,6 @@ export function applyThreadDetailEvent( Arr.filter((activity) => activity.id !== event.payload.activity.id), Arr.append(event.payload.activity), Arr.sort(activityOrder), - Arr.takeRight(limits.maxActivities), ); return { diff --git a/packages/client-runtime/src/state/threadRetention.ts b/packages/client-runtime/src/state/threadRetention.ts new file mode 100644 index 00000000000..119b963167c --- /dev/null +++ b/packages/client-runtime/src/state/threadRetention.ts @@ -0,0 +1,3 @@ +// Mobile thread routes unmount during back navigation. Retain the stream-backed +// state across short subscriber gaps without keeping every opened thread alive. +export const THREAD_STATE_IDLE_TTL_MS = 5 * 60_000; diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts new file mode 100644 index 00000000000..65cee0427eb --- /dev/null +++ b/packages/client-runtime/src/state/threadShell.ts @@ -0,0 +1,186 @@ +import type { + EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThreadShell, + ProjectId, + ScopedProjectRef, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentThreadShell } from "./models.ts"; +import { scopeThreadShell } from "./models.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { + arrayElementsEqual, + parseProjectRefCollectionKey, + parseThreadKey, + projectRefCollectionKey, + threadKey, + threadRefsEqual, +} from "./entities.ts"; + +const EMPTY_THREADS: ReadonlyArray = Object.freeze([]); +const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_INDEX: ReadonlyMap = new Map(); +const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< + ProjectId, + ReadonlyArray +> = new Map(); + +export function createEnvironmentThreadShellAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly snapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlyArray => + get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS, + ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)), + ); + + const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyMap => { + const threads = get(environmentThreadsAtom(environmentId)); + if (threads.length === 0) { + return EMPTY_THREAD_INDEX; + } + return new Map(threads.map((thread) => [thread.id, thread] as const)); + }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)), + ); + + const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(environmentThreadsAtom(environmentId)).map((thread) => ({ + environmentId, + threadId: thread.id, + })); + if (threadRefsEqual(previous, next)) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-thread-refs:${environmentId}`)); + }); + + const environmentThreadRefsByProjectAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyMap< + ProjectId, + ReadonlyArray + > = EMPTY_THREAD_REFS_BY_PROJECT; + return Atom.make((get) => { + const grouped = new Map(); + for (const thread of get(environmentThreadsAtom(environmentId))) { + const refs = grouped.get(thread.projectId); + const ref = { environmentId, threadId: thread.id }; + if (refs === undefined) { + grouped.set(thread.projectId, [ref]); + } else { + refs.push(ref); + } + } + if (grouped.size === 0) { + previous = EMPTY_THREAD_REFS_BY_PROJECT; + return previous; + } + const next = new Map>(); + for (const [projectId, refs] of grouped) { + const previousRefs = previous.get(projectId); + next.set( + projectId, + previousRefs !== undefined && threadRefsEqual(previousRefs, refs) ? previousRefs : refs, + ); + } + previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-thread-refs-by-project:${environmentId}`)); + }); + + const threadShellAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previousSource: OrchestrationThreadShell | null = null; + let previousValue: EnvironmentThreadShell | null = null; + return Atom.make((get) => { + const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source); + return previousValue; + }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); + }); + + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { + const projectRefs = parseProjectRefCollectionKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next: EnvironmentThreadShell[] = []; + const seen = new Set(); + for (const projectRef of projectRefs) { + const refs = + get(environmentThreadRefsByProjectAtom(projectRef.environmentId)).get( + projectRef.projectId, + ) ?? EMPTY_SCOPED_THREAD_REFS; + for (const ref of refs) { + const key = threadKey(ref); + if (seen.has(key)) { + continue; + } + seen.add(key); + const thread = get(threadShellAtomFamily(key)); + if (thread !== null) { + next.push(thread); + } + } + } + if (arrayElementsEqual(previous, next)) { + return previous; + } + previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-thread-shells-for-projects:${key}`)); + }); + + let previousThreadRefs: ReadonlyArray = []; + const threadRefsAtom = Atom.make((get) => { + const refs: ScopedThreadRef[] = []; + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + refs.push(...get(environmentThreadRefsAtom(environmentId))); + } + if (threadRefsEqual(previousThreadRefs, refs)) { + return previousThreadRefs; + } + previousThreadRefs = refs; + return refs; + }).pipe(Atom.withLabel("environment-thread-refs")); + + let previousThreadShells: ReadonlyArray = []; + const threadShellsAtom = Atom.make((get) => { + const next = get(threadRefsAtom).flatMap((ref) => { + const thread = get(threadShellAtomFamily(threadKey(ref))); + return thread === null ? [] : [thread]; + }); + if (arrayElementsEqual(previousThreadShells, next)) { + return previousThreadShells; + } + previousThreadShells = next; + return previousThreadShells; + }).pipe(Atom.withLabel("environment-thread-shell-list")); + + return { + environmentThreadsAtom, + environmentThreadIndexAtom, + environmentThreadRefsAtom, + environmentThreadRefsByProjectAtom, + threadRefsAtom, + threadShellsAtom, + threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => + threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), + threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + }; +} diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts new file mode 100644 index 00000000000..4da184962e6 --- /dev/null +++ b/packages/client-runtime/src/state/threadSort.ts @@ -0,0 +1,101 @@ +import type { ProjectId } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +export interface ThreadSortInput { + readonly createdAt: string; + readonly updatedAt: string; + readonly latestUserMessageAt?: string | null; + readonly messages?: ReadonlyArray<{ + readonly createdAt: string; + readonly role: string; + }>; +} + +export function toSortableTimestamp(iso: string | undefined): number | null { + if (!iso) return null; + const ms = Date.parse(iso); + return Number.isFinite(ms) ? ms : null; +} + +function getFirstSortableTimestamp(...values: Array): number | null { + for (const value of values) { + const timestamp = toSortableTimestamp(value ?? undefined); + if (timestamp !== null) { + return timestamp; + } + } + + return null; +} + +function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { + if (thread.latestUserMessageAt) { + return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; + } + + let latestUserMessageTimestamp: number | null = null; + + for (const message of thread.messages ?? []) { + if (message.role !== "user") continue; + const messageTimestamp = toSortableTimestamp(message.createdAt); + if (messageTimestamp === null) continue; + latestUserMessageTimestamp = + latestUserMessageTimestamp === null + ? messageTimestamp + : Math.max(latestUserMessageTimestamp, messageTimestamp); + } + + if (latestUserMessageTimestamp !== null) { + return latestUserMessageTimestamp; + } + + return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; +} + +export function getThreadSortTimestamp( + thread: ThreadSortInput, + sortOrder: SidebarThreadSortOrder | Exclude, +): number { + if (sortOrder === "created_at") { + return ( + getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY + ); + } + return getLatestUserMessageTimestamp(thread); +} + +export function sortThreads( + threads: readonly T[], + sortOrder: SidebarThreadSortOrder, +): T[] { + return Arr.sort( + threads, + Order.mapInput( + Order.Struct({ + timestamp: Order.flip(Order.Number), + id: Order.flip(Order.String), + }), + (thread: T) => ({ + timestamp: getThreadSortTimestamp(thread, sortOrder), + id: thread.id, + }), + ), + ); +} + +export function getLatestThreadForProject< + T extends { + readonly id: string; + readonly projectId: ProjectId; + readonly archivedAt: string | null; + } & ThreadSortInput, +>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { + return ( + sortThreads( + threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), + sortOrder, + )[0] ?? null + ); +} diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts new file mode 100644 index 00000000000..420f9412b68 --- /dev/null +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -0,0 +1,26 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; +import { createEnvironmentThreadStateAtoms } from "./threads.ts"; + +describe("createEnvironmentThreadStateAtoms", () => { + it("retains thread state across short subscriber gaps", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry | EnvironmentCacheStore, + never + >; + const threads = createEnvironmentThreadStateAtoms(runtime); + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const atom = threads.stateAtom(environmentId, threadId); + + expect(atom.idleTTL).toBe(THREAD_STATE_IDLE_TTL_MS); + expect(threads.stateAtom(environmentId, threadId)).toBe(atom); + expect(threads.stateAtom(environmentId, ThreadId.make("thread-2"))).not.toBe(atom); + }); +}); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts new file mode 100644 index 00000000000..b5b3b79dac8 --- /dev/null +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -0,0 +1,404 @@ +import { + EnvironmentId, + EventId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationThread, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + makeEnvironmentThreadState, + type EnvironmentThreadState, +} from "./threads.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const THREAD_ID = ThreadId.make("thread-1"); +const BASE_THREAD: OrchestrationThread = { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Cached thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + goal: null, + session: null, +}; + +type TestThreadInput = OrchestrationThreadStreamItem | Error; + +function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +function awaitThreadState( + observed: Queue.Queue, + predicate: (state: EnvironmentThreadState) => boolean, +) { + return Queue.take(observed).pipe( + Effect.repeat({ + until: predicate, + }), + ); +} + +const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { + readonly cached?: OrchestrationThread; +}) { + const inputs = yield* Queue.unbounded(); + const observed = yield* Queue.unbounded(); + const latest = yield* Ref.make(EMPTY_ENVIRONMENT_THREAD_STATE); + const retryCount = yield* Ref.make(0); + const subscriptionCount = yield* Ref.make(0); + const savedThreads = yield* Ref.make>([]); + const removedThreads = yield* Ref.make>([]); + const supervisorState = yield* SubscriptionRef.make( + AVAILABLE_CONNECTION_STATE, + ); + const streamFrom = (queue: Queue.Queue) => + Stream.fromQueue(queue).pipe( + Stream.mapEffect((input) => + input instanceof Error ? Effect.fail(input) : Effect.succeed(input), + ), + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeThread]: () => + Stream.unwrap( + Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( + Effect.map(() => streamFrom(inputs)), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisorSession = yield* SubscriptionRef.make>( + Option.some(testSession(client)), + ); + const prepared = yield* SubscriptionRef.make>(Option.none()); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: supervisorSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Ref.update(retryCount, (count) => count + 1), + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: (_environmentId, threadId) => + Effect.succeed( + threadId === THREAD_ID && options?.cached !== undefined + ? Option.some(options.cached) + : Option.none(), + ), + saveThread: (_environmentId, thread) => + Ref.update(savedThreads, (current) => [...current, thread]), + removeThread: (_environmentId, threadId) => + Ref.update(removedThreads, (current) => [...current, threadId]), + clear: () => Effect.void, + }); + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + yield* SubscriptionRef.changes(threadState).pipe( + Stream.runForEach((state) => + Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))), + ), + Effect.forkScoped, + ); + + return { + inputs, + observed, + latest, + retryCount, + subscriptionCount, + supervisorState, + supervisorSession, + savedThreads, + removedThreads, + replaceSession: SubscriptionRef.set(supervisorSession, Option.some(testSession(client))), + }; +}); + +const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => ({ + kind: "snapshot", + snapshot: { + snapshotSequence: 1, + thread, + }, +}); + +const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make("event-title"), + sequence, + occurredAt: "2026-04-01T01:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title, + updatedAt: "2026-04-01T01:00:00.000Z", + }, + }, +}); + +const deleted = (): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make("event-deleted"), + sequence: 3, + occurredAt: "2026-04-01T02:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.deleted", + payload: { + threadId: THREAD_ID, + deletedAt: "2026-04-01T02:00:00.000Z", + }, + }, +}); + +describe("EnvironmentThreads", () => { + it.effect("publishes cached data before a live snapshot arrives", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "cached" && Option.isSome(value.data), + ); + + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(Option.isNone(state.error)).toBe(true); + }), + ); + + it.effect("reduces live events and persists the latest thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, titleUpdated("Live title")); + + const state = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Live title", + ); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + expect(Option.getOrThrow(state.data).title).toBe("Live title"); + expect((yield* Ref.get(harness.savedThreads)).at(-1)?.title).toBe("Live title"); + }), + ); + + it.effect("ignores replayed thread events at or below the snapshot sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, titleUpdated("Replayed title", 1)); + yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); + + const state = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Live title", + ); + + expect(Option.getOrThrow(state.data).title).toBe("Live title"); + }), + ); + + it.effect("removes cached data when the thread is deleted", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, deleted()); + + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "deleted", + ); + + expect(Option.isNone(state.data)).toBe(true); + expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); + }), + ); + + it.effect("preserves data after a domain failure and resumes on a replacement session", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, new Error("stream failed")); + + const state = yield* awaitThreadState(harness.observed, (value) => + Option.isSome(value.error), + ); + + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(Option.getOrThrow(state.error)).toBe("stream failed"); + expect(yield* Ref.get(harness.retryCount)).toBe(0); + + yield* harness.replaceSession; + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { + break; + } + yield* Effect.yieldNow; + } + yield* Queue.offer( + harness.inputs, + snapshot({ + ...BASE_THREAD, + title: "Recovered thread", + }), + ); + const recovered = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Recovered thread", + ); + + expect(Option.isNone(recovered.error)).toBe(true); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + }), + ); + + it.effect("recovers from a transient domain failure without replacing the session", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Queue.offer(harness.inputs, new Error("thread not found yet")); + + const failed = yield* awaitThreadState(harness.observed, (value) => + Option.isSome(value.error), + ); + expect(Option.getOrThrow(failed.error)).toBe("thread not found yet"); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); + + yield* TestClock.adjust("250 millis"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { + break; + } + yield* Effect.yieldNow; + } + yield* Queue.offer( + harness.inputs, + snapshot({ + ...BASE_THREAD, + title: "Materialized thread", + }), + ); + + const recovered = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Materialized thread", + ); + + expect(Option.isNone(recovered.error)).toBe(true); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + expect(yield* Ref.get(harness.retryCount)).toBe(0); + }), + ); + + it.effect("does not overwrite a live snapshot when the supervisor becomes ready", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + + expect((yield* Ref.get(harness.latest)).status).toBe("live"); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts new file mode 100644 index 00000000000..bbb38f8d4be --- /dev/null +++ b/packages/client-runtime/src/state/threads.ts @@ -0,0 +1,256 @@ +import { + ORCHESTRATION_WS_METHODS, + type EnvironmentId as EnvironmentIdType, + type OrchestrationThread, + type OrchestrationThreadStreamItem, + type ThreadId as ThreadIdType, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { connectionProjectionPhase } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { subscribe } from "../rpc/client.ts"; +import { parseThreadKey, threadKey } from "./entities.ts"; +import { applyThreadDetailEvent } from "./threadReducer.ts"; +import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; + +export interface EnvironmentThreadState { + readonly data: Option.Option; + readonly status: EnvironmentThreadStatus; + readonly error: Option.Option; +} + +export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { + data: Option.none(), + status: "empty", + error: Option.none(), +}; + +function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { + return Option.isSome(data) ? "cached" : "empty"; +} + +function formatThreadError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Could not synchronize the thread."; +} + +export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( + threadId: ThreadIdType, +) { + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + const environmentId = supervisor.target.environmentId; + const cached = yield* cache.loadThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + Effect.as(Option.none()), + ), + ), + ); + const state = yield* SubscriptionRef.make({ + data: cached, + status: statusWithoutLiveData(cached), + error: Option.none(), + }); + const lastSequence = yield* SubscriptionRef.make(0); + const persistence = yield* Queue.sliding(1); + + const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( + thread: OrchestrationThread, + ) { + yield* cache.saveThread(environmentId, thread).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the thread cache.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); + }); + + yield* Stream.fromQueue(persistence).pipe( + Stream.debounce("500 millis"), + Stream.runForEach(persist), + Effect.forkScoped, + ); + + const setSynchronizing = SubscriptionRef.update(state, (current) => ({ + ...current, + status: "synchronizing" as const, + error: Option.none(), + })); + const setReady = SubscriptionRef.update(state, (current) => + current.status === "live" || current.status === "deleted" + ? current + : { + ...current, + status: "synchronizing" as const, + error: Option.none(), + }, + ); + const setDisconnected = SubscriptionRef.update(state, (current) => ({ + ...current, + status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), + })); + const setStreamError = (cause: Cause.Cause) => + SubscriptionRef.update(state, (current) => ({ + ...current, + status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), + error: Option.some(formatThreadError(cause)), + })); + + const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( + thread: OrchestrationThread, + ) { + yield* SubscriptionRef.set(state, { + data: Option.some(thread), + status: "live", + error: Option.none(), + }); + yield* Queue.offer(persistence, thread); + }); + + const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { + yield* SubscriptionRef.set(state, { + data: Option.none(), + status: "deleted", + error: Option.none(), + }); + yield* cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); + }); + + const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + item: OrchestrationThreadStreamItem, + ) { + if (item.kind === "snapshot") { + yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); + yield* setThread(item.snapshot.thread); + return; + } + + const sequence = yield* SubscriptionRef.get(lastSequence); + if (item.event.sequence <= sequence) { + return; + } + yield* SubscriptionRef.set(lastSequence, item.event.sequence); + + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data)) { + if (item.event.type === "thread.deleted") { + yield* setDeleted(); + } + return; + } + const result = applyThreadDetailEvent(current.data.value, item.event); + if (result.kind === "updated") { + yield* setThread(result.thread); + } else if (result.kind === "deleted") { + yield* setDeleted(); + } + }); + + yield* SubscriptionRef.changes(supervisor.state).pipe( + Stream.runForEach((connectionState) => { + switch (connectionProjectionPhase(connectionState)) { + case "synchronizing": + return setSynchronizing; + case "disconnected": + return setDisconnected; + case "ready": + return setReady; + } + }), + Effect.forkScoped, + ); + + yield* setSynchronizing; + yield* subscribe( + ORCHESTRATION_WS_METHODS.subscribeThread, + { threadId }, + { + onExpectedFailure: setStreamError, + retryExpectedFailureAfter: "250 millis", + }, + ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); + + yield* Effect.addFinalizer(() => + SubscriptionRef.get(state).pipe( + Effect.flatMap((current) => + Option.match(current.data, { + onNone: () => Effect.void, + onSome: persist, + }), + ), + ), + ); + + return state; +}); + +export function threadStateChanges(environmentId: EnvironmentIdType, threadId: ThreadIdType) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap(makeEnvironmentThreadState(threadId).pipe(Effect.map(SubscriptionRef.changes))), + ); +} + +export function createEnvironmentThreadStateAtoms( + runtime: Atom.AtomRuntime, +) { + const family = Atom.family((key: string) => { + const { environmentId, threadId } = parseThreadKey(key); + return runtime + .atom(threadStateChanges(environmentId, threadId), { + initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, + }) + .pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-state:${key}`), + ); + }); + + return { + stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => + family(threadKey({ environmentId, threadId })), + }; +} + +export * from "./archivedThreads.ts"; +export * from "./checkpointDiff.ts"; +export * from "./composerPathSearch.ts"; +export * from "./threadCommands.ts"; +export * from "./threadDetail.ts"; +export * from "./threadReducer.ts"; +export * from "./threadShell.ts"; diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts new file mode 100644 index 00000000000..846d0d50609 --- /dev/null +++ b/packages/client-runtime/src/state/vcs.ts @@ -0,0 +1,85 @@ +import { type VcsStatusResult, WS_METHODS } from "@t3tools/contracts"; +import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; + +export function createVcsEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + listRefs: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:list-refs", + tag: WS_METHODS.vcsListRefs, + staleTimeMs: 5_000, + }), + status: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:vcs:status", + subscribe: (input: EnvironmentRpcInput) => + subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( + Stream.mapAccum( + () => null as VcsStatusResult | null, + (current, event) => { + const next = applyGitStatusStreamEvent(current, event); + return [next, [next]] as const; + }, + ), + ), + }), + pull: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:pull", + tag: WS_METHODS.vcsPull, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + refreshStatus: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:refresh-status", + tag: WS_METHODS.vcsRefreshStatus, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + createWorktree: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:create-worktree", + tag: WS_METHODS.vcsCreateWorktree, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + removeWorktree: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:remove-worktree", + tag: WS_METHODS.vcsRemoveWorktree, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + createRef: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:create-ref", + tag: WS_METHODS.vcsCreateRef, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + switchRef: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:switch-ref", + tag: WS_METHODS.vcsSwitchRef, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + init: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:init", + tag: WS_METHODS.vcsInit, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + }; +} + +export * from "./gitActions.ts"; +export * from "./vcsAction.ts"; +export * from "./vcsRef.ts"; +export * from "./vcsStatus.ts"; diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts new file mode 100644 index 00000000000..7e618535ad8 --- /dev/null +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -0,0 +1,483 @@ +import { + EnvironmentId, + type GitActionProgressEvent, + type GitRunStackedActionResult, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { AtomCommandResult } from "./runtime.ts"; +import { + applyVcsActionProgressEvent, + beginVcsActionState, + consumeVcsActionProgress, + createVcsActionManager, + createVcsActionTransportId, + EMPTY_VCS_ACTION_STATE, + getVcsActionTargetKey, + normalizeVcsActionProgressEvent, + parseVcsActionTargetKey, + VcsActionMissingTerminalEventError, + VcsActionRemoteFailureError, + VcsActionTargetKeyParseError, + VcsActionUnavailableError, +} from "./vcsAction.ts"; + +const actionId = "action-123"; +const action = "commit_push" as const; +const cwd = "/repo"; +const environmentId = EnvironmentId.make("environment-1"); +const isVcsActionUnavailableError = Schema.is(VcsActionUnavailableError); +const result: GitRunStackedActionResult = { + action, + branch: { + status: "skipped_not_requested", + }, + commit: { + status: "created", + commitSha: "abc123", + subject: "Test commit", + }, + push: { + status: "pushed", + branch: "feature", + }, + pr: { + status: "skipped_not_requested", + }, + toast: { + title: "Changes pushed", + cta: { + kind: "none", + }, + }, +}; + +function progress(event: T): T { + return event; +} + +describe("vcsActionState", () => { + it("preserves malformed target key diagnostics and the native cause without copying the key", () => { + const key = "not-json-with-credential=do-not-log"; + let error: unknown; + + try { + parseVcsActionTargetKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(VcsActionTargetKeyParseError); + expect(error).toMatchObject({ keyLength: key.length, cause: expect.any(SyntaxError) }); + expect(error).not.toHaveProperty("key"); + expect((error as Error).message).not.toContain(key); + }); + + it("rejects invalid target key shapes", () => { + const key = JSON.stringify([environmentId]); + + expect(() => parseVcsActionTargetKey(key)).toThrowError(VcsActionTargetKeyParseError); + }); + + it("projects phase and hook progress without owning the async operation", () => { + const initial = beginVcsActionState({ + operation: "run_change_request", + label: "Running source control action", + actionId, + }); + const phase = applyVcsActionProgressEvent( + initial, + progress({ + actionId, + action, + cwd, + kind: "phase_started", + phase: "commit", + label: "Committing...", + }), + ); + const hook = applyVcsActionProgressEvent( + phase, + progress({ + actionId, + action, + cwd, + kind: "hook_started", + hookName: "post-commit", + }), + ); + const output = applyVcsActionProgressEvent( + hook, + progress({ + actionId, + action, + cwd, + kind: "hook_output", + hookName: "post-commit", + stream: "stdout", + text: "hook output", + }), + ); + const finished = applyVcsActionProgressEvent( + output, + progress({ + actionId, + action, + cwd, + kind: "hook_finished", + hookName: "post-commit", + exitCode: 0, + durationMs: 12, + }), + ); + + expect(phase).toMatchObject({ + isRunning: true, + currentLabel: "Committing...", + currentPhaseLabel: "Committing...", + }); + expect(output).toMatchObject({ + currentLabel: "Running post-commit...", + hookName: "post-commit", + lastOutputLine: "hook output", + }); + expect(finished).toMatchObject({ + currentLabel: "Committing...", + hookName: null, + lastOutputLine: null, + }); + }); + + it("retains a terminal action error for presentation", () => { + const initial = beginVcsActionState({ + operation: "run_change_request", + label: "Running source control action", + actionId, + }); + const failed = applyVcsActionProgressEvent( + initial, + progress({ + actionId, + action, + cwd, + kind: "action_failed", + phase: null, + message: "Push failed.", + }), + ); + + expect(failed).toMatchObject({ + isRunning: false, + operation: "run_change_request", + actionId, + action, + error: "Push failed.", + }); + }); + + it("ignores progress after a newer action owns the target", () => { + const current = beginVcsActionState({ + operation: "pull", + label: "Pulling latest changes", + actionId: "newer-action", + }); + + expect( + applyVcsActionProgressEvent( + current, + progress({ + actionId, + action, + cwd, + kind: "phase_started", + phase: "push", + label: "Pushing...", + }), + ), + ).toBe(current); + }); + + it("keys presentation state only when the environment and repository are known", () => { + expect( + getVcsActionTargetKey({ + environmentId, + cwd, + }), + ).toBe(JSON.stringify([environmentId, cwd])); + expect(getVcsActionTargetKey({ environmentId: null, cwd })).toBeNull(); + expect( + getVcsActionTargetKey({ + environmentId, + cwd: null, + }), + ).toBeNull(); + }); + + it("normalizes progress only for the matching environment-scoped action", () => { + const target = { environmentId, cwd }; + const otherTarget = { + environmentId: EnvironmentId.make("environment-2"), + cwd, + }; + const transportActionId = createVcsActionTransportId(target, actionId); + const event = progress({ + actionId: createVcsActionTransportId(otherTarget, actionId), + action, + cwd, + kind: "phase_started", + phase: "push", + label: "Pushing...", + }); + + expect(normalizeVcsActionProgressEvent(target, transportActionId, actionId, event)).toBeNull(); + expect( + normalizeVcsActionProgressEvent(target, transportActionId, actionId, { + ...event, + actionId: transportActionId, + }), + ).toEqual({ + ...event, + actionId, + }); + }); + + it.effect("consumes progress through the terminal event and returns its result", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const observed: GitActionProgressEvent[] = []; + const events: GitActionProgressEvent[] = [ + { + actionId: "unrelated-action", + action, + cwd, + kind: "phase_started", + phase: "commit", + label: "Ignored", + }, + { + actionId: transportActionId, + action, + cwd, + kind: "phase_started", + phase: "push", + label: "Pushing...", + }, + { + actionId: transportActionId, + action, + cwd, + kind: "action_finished", + result, + }, + ]; + + const actual = yield* consumeVcsActionProgress(Stream.fromIterable(events), { + target, + transportActionId, + actionId, + action, + onProgress: (event) => + Effect.sync(() => { + observed.push(event); + }), + }); + + expect(actual).toEqual(result); + expect(observed.map((event) => event.actionId)).toEqual([actionId, actionId]); + expect(observed.map((event) => event.kind)).toEqual(["phase_started", "action_finished"]); + }), + ); + + it.effect("retains structural remote failure context without copying the remote payload", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const remoteMessage = "The remote rejected the push with credential=do-not-log."; + const error = yield* consumeVcsActionProgress( + Stream.fromIterable([ + { + actionId: transportActionId, + action, + cwd, + kind: "action_failed", + phase: "push", + message: remoteMessage, + }, + ]), + { + target, + transportActionId, + actionId, + action, + onProgress: () => Effect.void, + }, + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsActionRemoteFailureError); + expect(error).toMatchObject({ + actionId, + transportActionId, + action, + environmentId, + cwd, + phase: "push", + remoteMessageLength: remoteMessage.length, + }); + expect(error).not.toHaveProperty("detail"); + expect(error.message).toBe("Source control action 'commit_push' failed during push."); + expect(error.message).not.toContain(remoteMessage); + }), + ); + + it.effect("reports a missing terminal event as a protocol failure", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const error = yield* consumeVcsActionProgress( + Stream.fromIterable([ + { + actionId: transportActionId, + action, + cwd, + kind: "phase_started", + phase: "commit", + label: "Committing...", + }, + ]), + { + target, + transportActionId, + actionId, + action, + onProgress: () => Effect.void, + }, + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsActionMissingTerminalEventError); + expect(error).toMatchObject({ + actionId, + transportActionId, + action, + environmentId, + cwd, + }); + expect(error.message).toBe( + "Source control action 'commit_push' ended without a terminal result.", + ); + }), + ); + + it("keys mutation ownership by environment and cwd", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const manager = createVcsActionManager(runtime); + const registry = AtomRegistry.make(); + const target = { environmentId, cwd }; + const otherTarget = { + environmentId: EnvironmentId.make("environment-2"), + cwd, + }; + + expect(manager.runStackedAction(target)).toBe(manager.runStackedAction({ ...target })); + expect(manager.runStackedAction(target)).not.toBe(manager.runStackedAction(otherTarget)); + expect(registry.get(manager.stateAtom(target))).toEqual(EMPTY_VCS_ACTION_STATE); + + registry.dispose(); + }); + + it("retains the incomplete target and operation when tracking is unavailable", async () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const manager = createVcsActionManager(runtime); + const registry = AtomRegistry.make(); + const result = await manager.track( + registry, + { environmentId, cwd: null }, + { operation: "pull", label: "Pulling latest changes" }, + async () => AsyncResult.success(undefined), + ); + + expect(AsyncResult.isFailure(result)).toBe(true); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(VcsActionUnavailableError); + if (!isVcsActionUnavailableError(error)) { + throw error; + } + expect(error).toMatchObject({ + operation: "pull", + environmentId, + cwd: null, + }); + expect(error.message).toBe("Source control operation 'pull' is unavailable."); + } + + registry.dispose(); + }); + + it("tracks finite mutations without letting an older completion clear newer state", async () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const manager = createVcsActionManager(runtime); + const registry = AtomRegistry.make(); + const target = { environmentId, cwd }; + let finishFirst!: () => void; + let failSecond!: (error: Error) => void; + const firstAction = new Promise>((resolve) => { + finishFirst = () => resolve(AsyncResult.success(undefined)); + }); + const secondAction = new Promise>((resolve) => { + failSecond = (error) => resolve(AsyncResult.failure(Cause.fail(error))); + }); + + const first = manager.track( + registry, + target, + { operation: "pull", label: "Pulling latest changes" }, + () => firstAction, + ); + const firstActionId = registry.get(manager.stateAtom(target)).actionId; + const second = manager.track( + registry, + target, + { operation: "switch_ref", label: "Switching branch" }, + () => secondAction, + ); + const secondActionId = registry.get(manager.stateAtom(target)).actionId; + + finishFirst(); + await first; + expect(registry.get(manager.stateAtom(target))).toMatchObject({ + actionId: secondActionId, + isRunning: true, + operation: "switch_ref", + }); + expect(secondActionId).not.toBe(firstActionId); + + failSecond(new Error("switch failed")); + const secondFailure = await second; + expect(AsyncResult.isFailure(secondFailure)).toBe(true); + expect(registry.get(manager.stateAtom(target))).toMatchObject({ + actionId: secondActionId, + error: "switch failed", + isRunning: false, + operation: "switch_ref", + }); + + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts new file mode 100644 index 00000000000..8ae3219a243 --- /dev/null +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -0,0 +1,576 @@ +import { + EnvironmentId, + type EnvironmentId as EnvironmentIdType, + GitActionProgressPhase, + type GitActionProgressEvent, + type GitRunStackedActionInput, + type GitRunStackedActionResult, + GitStackedAction, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { runStream } from "../rpc/client.ts"; +import { + createRuntimeCommand, + runStreamInEnvironment, + type AtomCommand, + type AtomCommandResult, +} from "./runtime.ts"; +import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; + +export const VcsActionOperation = Schema.Literals([ + "refresh_status", + "run_change_request", + "pull", + "switch_ref", + "create_ref", + "create_worktree", + "init", + "publish_repository", + "prepare_pull_request_thread", +]); +export type VcsActionOperation = typeof VcsActionOperation.Type; + +export interface VcsActionState { + readonly isRunning: boolean; + readonly operation: VcsActionOperation | null; + readonly actionId: string | null; + readonly action: GitStackedAction | null; + readonly currentLabel: string | null; + readonly currentPhaseLabel: string | null; + readonly hookName: string | null; + readonly lastOutputLine: string | null; + readonly phaseStartedAtMs: number | null; + readonly hookStartedAtMs: number | null; + readonly error: string | null; +} + +export interface VcsActionTarget { + readonly environmentId: EnvironmentIdType | null; + readonly cwd: string | null; +} + +export interface ResolvedVcsActionTarget { + readonly environmentId: EnvironmentIdType; + readonly cwd: string; +} + +export interface BeginVcsActionInput { + readonly operation: VcsActionOperation; + readonly label: string; + readonly actionId?: string; +} + +export interface RunVcsStackedActionInput { + readonly actionId: string; + readonly action: GitStackedAction; + readonly commitMessage?: string; + readonly featureBranch?: boolean; + readonly filePaths?: ReadonlyArray; + readonly onProgress?: (event: GitActionProgressEvent) => void; +} + +export class VcsActionUnavailableError extends Schema.TaggedErrorClass()( + "VcsActionUnavailableError", + { + operation: VcsActionOperation, + environmentId: Schema.NullOr(EnvironmentId), + cwd: Schema.NullOr(Schema.String), + }, +) { + override get message(): string { + return `Source control operation '${this.operation.replaceAll("_", " ")}' is unavailable.`; + } +} + +export class VcsActionRemoteFailureError extends Schema.TaggedErrorClass()( + "VcsActionRemoteFailureError", + { + actionId: Schema.String, + transportActionId: Schema.String, + action: GitStackedAction, + environmentId: EnvironmentId, + cwd: Schema.String, + phase: Schema.NullOr(GitActionProgressPhase), + remoteMessageLength: Schema.Number, + }, +) { + override get message(): string { + const phase = this.phase === null ? "execution" : this.phase; + return `Source control action '${this.action}' failed during ${phase}.`; + } +} + +export class VcsActionMissingTerminalEventError extends Schema.TaggedErrorClass()( + "VcsActionMissingTerminalEventError", + { + actionId: Schema.String, + transportActionId: Schema.String, + action: GitStackedAction, + environmentId: EnvironmentId, + cwd: Schema.String, + }, +) { + override get message(): string { + return `Source control action '${this.action}' ended without a terminal result.`; + } +} + +export class VcsActionTargetKeyParseError extends Schema.TaggedErrorClass()( + "VcsActionTargetKeyParseError", + { + keyLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid source control action target key (${this.keyLength} characters).`; + } +} + +export const VcsActionExecutionError = Schema.Union([ + VcsActionRemoteFailureError, + VcsActionMissingTerminalEventError, +]); +export type VcsActionExecutionError = typeof VcsActionExecutionError.Type; + +export const EMPTY_VCS_ACTION_STATE = Object.freeze({ + isRunning: false, + operation: null, + actionId: null, + action: null, + currentLabel: null, + currentPhaseLabel: null, + hookName: null, + lastOutputLine: null, + phaseStartedAtMs: null, + hookStartedAtMs: null, + error: null, +}); + +const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); +let nextLocalActionId = 0; +const decodeVcsActionTargetKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.String]), +); + +export const vcsActionStateAtom = Atom.family((key: string) => { + return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`vcs-action:${key}`), + ); +}); + +export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( + Atom.keepAlive, + Atom.withLabel("vcs-action:null"), +); + +export function getVcsActionTargetKey(target: VcsActionTarget): string | null { + if (target.environmentId === null || target.cwd === null) { + return null; + } + return JSON.stringify([target.environmentId, target.cwd]); +} + +export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { + try { + const [environmentId, cwd] = decodeVcsActionTargetKey(JSON.parse(key)); + return { environmentId, cwd }; + } catch (cause) { + throw new VcsActionTargetKeyParseError({ keyLength: key.length, cause }); + } +} + +export function getVcsActionStateAtom(target: VcsActionTarget) { + const key = getVcsActionTargetKey(target); + return key === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); +} + +function createLocalActionId(): string { + nextLocalActionId += 1; + return `local-vcs-action:${nextLocalActionId}`; +} + +export function beginVcsActionState( + input: BeginVcsActionInput, +): VcsActionState & { readonly actionId: string } { + const actionId = input.actionId ?? createLocalActionId(); + const startedAt = nowMs(); + return { + ...EMPTY_VCS_ACTION_STATE, + isRunning: true, + operation: input.operation, + actionId, + currentLabel: input.label, + currentPhaseLabel: input.label, + phaseStartedAtMs: startedAt, + }; +} + +export function failVcsActionState( + operation: VcsActionOperation, + actionId: string, + error: unknown, +): VcsActionState { + return { + ...EMPTY_VCS_ACTION_STATE, + operation, + actionId, + error: error instanceof Error ? error.message : "Source control action failed.", + }; +} + +export function createVcsActionTransportId( + target: ResolvedVcsActionTarget, + actionId: string, +): string { + const targetKey = JSON.stringify([target.environmentId, target.cwd]); + return `${targetKey.length}:${targetKey}${actionId}`; +} + +export function normalizeVcsActionProgressEvent( + target: ResolvedVcsActionTarget, + transportActionId: string, + actionId: string, + event: GitActionProgressEvent, +): GitActionProgressEvent | null { + if (event.actionId !== transportActionId || event.cwd !== target.cwd) { + return null; + } + return { + ...event, + actionId, + }; +} + +export function consumeVcsActionProgress( + stream: Stream.Stream, + input: { + readonly target: ResolvedVcsActionTarget; + readonly transportActionId: string; + readonly actionId: string; + readonly action: GitStackedAction; + readonly onProgress: (event: GitActionProgressEvent) => Effect.Effect; + }, +): Effect.Effect { + return Effect.suspend(() => { + let terminalEvent: GitActionProgressEvent | null = null; + return stream.pipe( + Stream.runForEach((event) => { + const normalized = normalizeVcsActionProgressEvent( + input.target, + input.transportActionId, + input.actionId, + event, + ); + if (normalized === null) { + return Effect.void; + } + if (normalized.kind === "action_finished" || normalized.kind === "action_failed") { + terminalEvent = normalized; + } + return input.onProgress(normalized); + }), + Effect.flatMap(() => { + if (terminalEvent?.kind === "action_finished") { + return Effect.succeed(terminalEvent.result); + } + if (terminalEvent?.kind === "action_failed") { + return Effect.fail( + new VcsActionRemoteFailureError({ + actionId: input.actionId, + transportActionId: input.transportActionId, + action: terminalEvent.action, + environmentId: input.target.environmentId, + cwd: input.target.cwd, + phase: terminalEvent.phase, + remoteMessageLength: terminalEvent.message.length, + }), + ); + } + return Effect.fail( + new VcsActionMissingTerminalEventError({ + actionId: input.actionId, + transportActionId: input.transportActionId, + action: input.action, + environmentId: input.target.environmentId, + cwd: input.target.cwd, + }), + ); + }), + ); + }); +} + +export function applyVcsActionProgressEvent( + current: VcsActionState, + event: GitActionProgressEvent, +): VcsActionState { + if (current.actionId !== event.actionId) { + return current; + } + const now = nowMs(); + + switch (event.kind) { + case "action_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + phaseStartedAtMs: now, + hookStartedAtMs: null, + hookName: null, + lastOutputLine: null, + error: null, + }; + case "phase_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: event.label, + currentPhaseLabel: event.label, + phaseStartedAtMs: now, + hookStartedAtMs: null, + hookName: null, + lastOutputLine: null, + error: null, + }; + case "hook_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: `Running ${event.hookName}...`, + hookName: event.hookName, + hookStartedAtMs: now, + lastOutputLine: null, + error: null, + }; + case "hook_output": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + lastOutputLine: event.text, + error: null, + }; + case "hook_finished": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: current.currentPhaseLabel, + hookName: null, + hookStartedAtMs: null, + lastOutputLine: null, + error: null, + }; + case "action_finished": + return { + ...current, + isRunning: false, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + error: null, + }; + case "action_failed": + return { + ...EMPTY_VCS_ACTION_STATE, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + error: event.message, + }; + } +} + +export function createVcsActionManager( + runtime: Atom.AtomRuntime, +) { + const runStackedActionCommands = new Map< + string, + AtomCommand + >(); + const getRunStackedActionCommand = (requestedTarget: VcsActionTarget) => { + const targetKey = getVcsActionTargetKey(requestedTarget); + const commandKey = + targetKey ?? + JSON.stringify([ + "vcs-action-target:unavailable", + requestedTarget.environmentId, + requestedTarget.cwd, + ]); + const existing = runStackedActionCommands.get(commandKey); + if (existing !== undefined) { + return existing; + } + const target = targetKey === null ? null : parseVcsActionTargetKey(targetKey); + const stateAtom = targetKey === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(targetKey); + const command = createRuntimeCommand< + EnvironmentRegistry | R, + E, + RunVcsStackedActionInput, + GitRunStackedActionResult, + unknown + >(runtime, { + label: `vcs-action:run-stacked:${commandKey}`, + scheduler: vcsCommandScheduler, + concurrency: { mode: "serial", key: () => commandKey }, + execute: (input: RunVcsStackedActionInput, registry) => { + if (target === null) { + return Effect.fail( + new VcsActionUnavailableError({ + operation: "run_change_request", + environmentId: requestedTarget.environmentId, + cwd: requestedTarget.cwd, + }), + ); + } + const transportActionId = createVcsActionTransportId(target, input.actionId); + registry.set( + stateAtom, + beginVcsActionState({ + operation: "run_change_request", + label: "Running source control action", + actionId: input.actionId, + }), + ); + + const rpcInput: GitRunStackedActionInput = { + actionId: transportActionId, + cwd: target.cwd, + action: input.action, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch ? { featureBranch: true } : {}), + ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + }; + return consumeVcsActionProgress( + runStreamInEnvironment( + target.environmentId, + runStream(WS_METHODS.gitRunStackedAction, rpcInput), + ), + { + target, + transportActionId, + actionId: input.actionId, + action: input.action, + onProgress: (event) => + Effect.sync(() => { + const current = registry.get(stateAtom); + if (current.actionId !== input.actionId) { + return; + } + registry.set(stateAtom, applyVcsActionProgressEvent(current, event)); + if (input.onProgress !== undefined) { + try { + input.onProgress(event); + } catch { + // Presentation callbacks must not fail the source-control operation. + } + } + }), + }, + ).pipe( + Effect.tapError((error) => + Effect.sync(() => { + const current = registry.get(stateAtom); + if (current.actionId === input.actionId && current.isRunning) { + registry.set( + stateAtom, + failVcsActionState("run_change_request", input.actionId, error), + ); + } + }), + ), + ); + }, + }); + runStackedActionCommands.set(commandKey, command); + return command; + }; + + const setState = ( + registry: AtomRegistry.AtomRegistry, + target: VcsActionTarget, + update: (current: VcsActionState) => VcsActionState, + ): void => { + const key = getVcsActionTargetKey(target); + if (key === null) { + return; + } + const stateAtom = vcsActionStateAtom(key); + registry.set(stateAtom, update(registry.get(stateAtom))); + }; + + return { + stateAtom: getVcsActionStateAtom, + runStackedAction: (target: VcsActionTarget) => getRunStackedActionCommand(target), + track: async ( + registry: AtomRegistry.AtomRegistry, + target: VcsActionTarget, + input: BeginVcsActionInput, + action: () => Promise>, + ): Promise> => { + const key = getVcsActionTargetKey(target); + if (key === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: input.operation, + environmentId: target.environmentId, + cwd: target.cwd, + }), + ), + ); + } + const stateAtom = vcsActionStateAtom(key); + const next = beginVcsActionState(input); + registry.set(stateAtom, next); + const result = await action(); + const current = registry.get(stateAtom); + if (current.actionId !== next.actionId) { + return result; + } + if (AsyncResult.isSuccess(result) || Cause.hasInterruptsOnly(result.cause)) { + registry.set(stateAtom, EMPTY_VCS_ACTION_STATE); + } else { + if (registry.get(stateAtom).actionId === next.actionId) { + registry.set( + stateAtom, + failVcsActionState(input.operation, next.actionId, Cause.squash(result.cause)), + ); + } + } + return result; + }, + resetError: ( + registry: AtomRegistry.AtomRegistry, + target: VcsActionTarget, + operation: VcsActionOperation, + ): void => { + setState(registry, target, (current) => + !current.isRunning && current.operation === operation ? EMPTY_VCS_ACTION_STATE : current, + ); + }, + }; +} diff --git a/packages/client-runtime/src/state/vcsCommandScheduler.ts b/packages/client-runtime/src/state/vcsCommandScheduler.ts new file mode 100644 index 00000000000..a11b157bb2d --- /dev/null +++ b/packages/client-runtime/src/state/vcsCommandScheduler.ts @@ -0,0 +1,13 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import { createAtomCommandScheduler, type AtomCommandConcurrency } from "./runtime.ts"; + +export const vcsCommandScheduler = createAtomCommandScheduler(); + +export const vcsCommandConcurrency: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentId; + readonly input: { readonly cwd: string }; +}> = { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), +}; diff --git a/packages/client-runtime/src/state/vcsRef.ts b/packages/client-runtime/src/state/vcsRef.ts new file mode 100644 index 00000000000..5e879356d2f --- /dev/null +++ b/packages/client-runtime/src/state/vcsRef.ts @@ -0,0 +1,9 @@ +import type { EnvironmentId, VcsRef as ContractVcsRef } from "@t3tools/contracts"; + +export interface VcsRefTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query?: string | null; +} + +export type VcsRef = ContractVcsRef; diff --git a/packages/client-runtime/src/state/vcsStatus.ts b/packages/client-runtime/src/state/vcsStatus.ts new file mode 100644 index 00000000000..0a301fa86f3 --- /dev/null +++ b/packages/client-runtime/src/state/vcsStatus.ts @@ -0,0 +1,6 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface VcsStatusTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} diff --git a/packages/client-runtime/src/terminalSessionState.test.ts b/packages/client-runtime/src/terminalSessionState.test.ts deleted file mode 100644 index 401536915de..00000000000 --- a/packages/client-runtime/src/terminalSessionState.test.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { - EnvironmentId, - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, - ThreadId, -} from "@t3tools/contracts"; - -import { - createTerminalSessionManager, - getKnownTerminalSessionListFilter, - knownTerminalSessionsAtom, - runningTerminalIdsAtom, - terminalSessionStateAtom, - type KnownTerminalSessionTarget, -} from "./terminalSessionState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), - terminalId: "term-1", -} as const; - -const BASE_SNAPSHOT: TerminalSessionSnapshot = { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - history: "hello", - exitCode: null, - exitSignal: null, - label: "Terminal 1", - updatedAt: "2026-04-01T00:00:00.000Z", -}; - -type TerminalSessionManager = ReturnType; - -function applyAttachEvents( - manager: TerminalSessionManager, - target: KnownTerminalSessionTarget, - events: ReadonlyArray, -): void { - manager.attach({ - environmentId: target.environmentId, - terminal: { - threadId: target.threadId, - terminalId: target.terminalId, - }, - client: { - terminal: { - attach: (_input, listener) => { - events.forEach(listener); - return () => undefined; - }, - }, - }, - })(); -} - -function applyMetadataEvents( - manager: TerminalSessionManager, - environmentId: EnvironmentId, - events: ReadonlyArray, -): void { - manager.subscribeMetadata({ - environmentId, - client: { - terminal: { - onMetadata: (listener) => { - events.forEach(listener); - return () => undefined; - }, - }, - }, - })(); -} - -describe("createTerminalSessionManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("hydrates from started snapshots and appends output events", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: " world", - }, - ]); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - summary: null, - buffer: "hello world", - status: "running", - error: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - }); - }); - - it("caps retained output", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - maxBufferBytes: 5, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "abcdef", - }, - ]); - - expect(manager.getSnapshot(TARGET).buffer).toBe("bcdef"); - }); - - it("caps retained output by utf-8 byte length", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - maxBufferBytes: 4, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "🙂🙂", - }, - ]); - - expect(manager.getSnapshot(TARGET).buffer).toBe("🙂"); - }); - - it("invalidates one environment without clearing others", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - const otherTarget = { - environmentId: EnvironmentId.make("env-remote"), - threadId: ThreadId.make("thread-1"), - terminalId: "term-1", - } as const; - - for (const target of [TARGET, otherTarget]) { - applyAttachEvents(manager, target, [ - { - type: "output", - threadId: target.threadId, - terminalId: target.terminalId, - data: target.environmentId, - }, - ]); - } - - manager.invalidateEnvironment(TARGET.environmentId); - - expect(manager.getSnapshot(TARGET).buffer).toBe(""); - expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote"); - }); - - it("lists known sessions for a thread ordered by terminal id (numeric-aware)", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "snapshot", - terminals: [ - { - threadId: TARGET.threadId, - terminalId: "term-10", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 125, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: false, - label: "Terminal 10", - }, - { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:00.000Z", - hasRunningSubprocess: false, - label: "Terminal 1", - }, - { - threadId: TARGET.threadId, - terminalId: "term-2", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 124, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:02.000Z", - hasRunningSubprocess: false, - label: "Terminal 2", - }, - ], - }, - ]); - - expect( - manager - .listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }) - .map((session) => session.target.terminalId), - ).toEqual(["term-1", "term-2", "term-10"]); - }); - - it("drops known sessions when an environment is invalidated", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "hello", - }, - ]); - - manager.invalidateEnvironment(TARGET.environmentId); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toEqual([]); - }); - - it("removes closed sessions from the known-session index while keeping local closed state", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: false, - label: "Terminal 1", - }, - }, - ]); - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "closed", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "remove", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toEqual([]); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - buffer: "hello", - status: "closed", - summary: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - }); - }); - - it("clears locally retained closed state on reset", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "closed", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - - manager.reset(); - - expect(manager.getSnapshot(TARGET)).toEqual({ - summary: null, - buffer: "", - status: "closed", - error: null, - hasRunningSubprocess: false, - updatedAt: null, - version: 0, - }); - }); - - it("syncs snapshots returned from open calls immediately", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: { - ...BASE_SNAPSHOT, - history: "prompt$ ", - updatedAt: "2026-04-01T00:00:03.000Z", - }, - }, - ]); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - buffer: "prompt$ ", - status: "running", - updatedAt: "2026-04-01T00:00:03.000Z", - }); - }); - - it("syncs authoritative metadata snapshots and removes missing environment terminals", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - ]); - applyAttachEvents( - manager, - { - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - terminalId: "term-2", - }, - [ - { - type: "snapshot", - snapshot: { - ...BASE_SNAPSHOT, - terminalId: "term-2", - label: "Terminal 2", - updatedAt: "2026-04-01T00:00:02.000Z", - }, - }, - ], - ); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "snapshot", - terminals: [ - { - threadId: TARGET.threadId, - terminalId: "term-2", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: true, - label: "Terminal 2", - }, - ], - }, - ]); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toMatchObject([ - { - target: { - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - terminalId: "term-2", - }, - state: { - summary: { - terminalId: "term-2", - cwd: "/repo", - }, - hasRunningSubprocess: true, - }, - }, - ]); - }); - - it("updates listed session metadata when existing session activity changes", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: false, - label: "Terminal 1", - }, - }, - ]); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: true, - label: "Terminal 1", - }, - }, - ]); - - expect( - manager.listSessions({ environmentId: TARGET.environmentId, threadId: TARGET.threadId }), - ).toMatchObject([ - { - state: { - hasRunningSubprocess: true, - }, - }, - ]); - }); - - it("derives session atoms from structurally equal target objects", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: true, - label: "Terminal 1", - }, - }, - ]); - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - ]); - - const equalTarget = { ...TARGET }; - const filter = getKnownTerminalSessionListFilter({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }); - expect(filter).not.toBeNull(); - if (filter === null) { - return; - } - - expect(atomRegistry.get(terminalSessionStateAtom(equalTarget))).toMatchObject({ - buffer: BASE_SNAPSHOT.history, - hasRunningSubprocess: true, - }); - expect( - atomRegistry.get(knownTerminalSessionsAtom({ ...filter })).map((session) => session.target), - ).toEqual([TARGET]); - expect(atomRegistry.get(runningTerminalIdsAtom({ ...filter }))).toEqual([TARGET.terminalId]); - }); -}); diff --git a/packages/client-runtime/src/terminalSessionState.ts b/packages/client-runtime/src/terminalSessionState.ts deleted file mode 100644 index 916dc753f41..00000000000 --- a/packages/client-runtime/src/terminalSessionState.ts +++ /dev/null @@ -1,620 +0,0 @@ -import type { - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, - TerminalSummary, - EnvironmentId, -} from "@t3tools/contracts"; -import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Result from "effect/Result"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface TerminalSessionState { - readonly summary: TerminalSummary | null; - readonly buffer: string; - readonly status: TerminalSessionSnapshot["status"] | "closed"; - readonly error: string | null; - readonly hasRunningSubprocess: boolean; - readonly updatedAt: string | null; - readonly version: number; -} - -export interface TerminalBufferState { - readonly buffer: string; - readonly status: TerminalSessionSnapshot["status"] | "closed"; - readonly error: string | null; - readonly updatedAt: string | null; - readonly version: number; -} - -export interface TerminalSessionTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; - readonly terminalId: string | null; -} - -export interface KnownTerminalSessionTarget { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly terminalId: string; -} - -export interface KnownTerminalSession { - readonly target: KnownTerminalSessionTarget; - readonly state: TerminalSessionState; -} - -export interface KnownTerminalMetadata { - readonly target: KnownTerminalSessionTarget; - readonly summary: TerminalSummary; -} - -export interface TerminalSessionListFilter { - readonly environmentId: EnvironmentId | null; - readonly threadId?: ThreadId | null; - readonly terminalId?: string | null; -} - -export interface KnownTerminalSessionListFilter { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId | null; - readonly terminalId: string | null; -} - -export interface TerminalSessionManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly maxBufferBytes?: number; -} - -export interface TerminalMetadataClient { - readonly terminal: { - readonly onMetadata: ( - listener: (event: TerminalMetadataStreamEvent) => void, - options?: { readonly onResubscribe?: () => void }, - ) => () => void; - }; -} - -export interface TerminalAttachClient { - readonly terminal: { - readonly attach: ( - input: TerminalAttachInput, - listener: (event: TerminalAttachStreamEvent) => void, - options?: { readonly onResubscribe?: () => void }, - ) => () => void; - }; -} - -export type TerminalSubscribeMetadataInput = { - readonly environmentId: EnvironmentId; - readonly client: TerminalMetadataClient; - readonly options?: { readonly onResubscribe?: () => void }; -}; - -export type TerminalAttachSessionInput = { - readonly environmentId: EnvironmentId; - readonly client: TerminalAttachClient; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: (event: TerminalAttachStreamEvent) => void; - readonly options?: { readonly onResubscribe?: () => void }; -}; - -export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ - buffer: "", - status: "closed", - error: null, - updatedAt: null, - version: 0, -}); - -export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ - summary: null, - buffer: "", - status: "closed", - error: null, - hasRunningSubprocess: false, - updatedAt: null, - version: 0, -}); - -const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze>([]); -const EMPTY_TERMINAL_ID_LIST = Object.freeze>([]); -const DEFAULT_MAX_BUFFER_BYTES = 512 * 1024; -const knownTerminalMetadataEnvironmentIds = new Set(); -const knownTerminalBufferTargets = new Map(); -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); -const terminalIdOrder = Order.make( - (left, right) => left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1, -); -const knownTerminalSessionOrder = Order.mapInput( - terminalIdOrder, - (session: KnownTerminalSession) => session.target.terminalId, -); - -export const terminalSessionMetadataAtom = Atom.family((environmentId: EnvironmentId) => { - knownTerminalMetadataEnvironmentIds.add(environmentId); - return Atom.make>({}).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:metadata:${environmentId}`), - ); -}); - -export const terminalSessionBufferAtom = Atom.family((target: KnownTerminalSessionTarget) => { - const key = keyFromKnownTarget(target); - knownTerminalBufferTargets.set(key, target); - return Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:buffer:${key}`), - ); -}); - -export const EMPTY_TERMINAL_BUFFER_ATOM = Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:buffer:null"), -); - -export const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:state:null"), -); - -export const EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make(EMPTY_KNOWN_TERMINAL_SESSIONS).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:known:null"), -); - -export const EMPTY_TERMINAL_ID_LIST_ATOM = Atom.make(EMPTY_TERMINAL_ID_LIST).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:running-terminal-ids:null"), -); - -export function getKnownTerminalSessionTarget( - target: TerminalSessionTarget, -): KnownTerminalSessionTarget | null { - if (target.environmentId === null || target.threadId === null || target.terminalId === null) { - return null; - } - - return { - environmentId: target.environmentId, - threadId: target.threadId, - terminalId: target.terminalId, - }; -} - -export function getKnownTerminalSessionListFilter( - filter: TerminalSessionListFilter, -): KnownTerminalSessionListFilter | null { - if (filter.environmentId === null) { - return null; - } - - return { - environmentId: filter.environmentId, - threadId: filter.threadId ?? null, - terminalId: filter.terminalId ?? null, - }; -} - -function knownTargetFromSummary( - environmentId: EnvironmentId, - summary: TerminalSummary, -): KnownTerminalSessionTarget { - return { - environmentId, - threadId: ThreadId.make(summary.threadId), - terminalId: summary.terminalId, - }; -} - -function keyFromKnownTarget(target: KnownTerminalSessionTarget): string { - return `${target.environmentId}:${target.threadId}:${target.terminalId}`; -} - -function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { - if (maxBufferBytes <= 0) { - return ""; - } - - const encoded = textEncoder.encode(buffer); - if (encoded.byteLength <= maxBufferBytes) { - return buffer; - } - - let start = encoded.byteLength - maxBufferBytes; - while (start < encoded.length) { - const byte = encoded[start]; - if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { - break; - } - start += 1; - } - - return textDecoder.decode(encoded.subarray(start)); -} - -function bufferFromSnapshot( - snapshot: TerminalSessionSnapshot, - maxBufferBytes: number, -): TerminalBufferState { - return { - buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), - status: snapshot.status, - error: null, - updatedAt: snapshot.updatedAt, - version: 1, - }; -} - -function latestTimestamp(left: string | null, right: string | null): string | null { - if (left === null) return right; - if (right === null) return left; - return Date.parse(left) >= Date.parse(right) ? left : right; -} - -function combineSessionState( - summary: TerminalSummary | null, - buffer: TerminalBufferState, -): TerminalSessionState { - return { - summary, - buffer: buffer.buffer, - status: summary?.status ?? buffer.status, - error: buffer.error, - hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, - updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), - version: buffer.version, - }; -} - -function listKnownSessionsFromMetadata( - metadata: Record, - getBuffer: (target: KnownTerminalSessionTarget) => TerminalBufferState, - filter?: Partial, -): ReadonlyArray { - return pipe( - Object.values(metadata), - Arr.filterMap(({ target, summary }) => { - if (filter?.environmentId && target.environmentId !== filter.environmentId) { - return Result.failVoid; - } - if (filter?.threadId && target.threadId !== filter.threadId) { - return Result.failVoid; - } - if (filter?.terminalId && target.terminalId !== filter.terminalId) { - return Result.failVoid; - } - return Result.succeed({ - target, - state: combineSessionState(summary, getBuffer(target)), - }); - }), - Arr.sort(knownTerminalSessionOrder), - ); -} - -export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) => - Atom.make((get) => { - const targetKey = keyFromKnownTarget(target); - return combineSessionState( - get(terminalSessionMetadataAtom(target.environmentId))[targetKey]?.summary ?? null, - get(terminalSessionBufferAtom(target)), - ); - }).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:state:${keyFromKnownTarget(target)}`)), -); - -export const knownTerminalSessionsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => - Atom.make((get) => - listKnownSessionsFromMetadata( - get(terminalSessionMetadataAtom(filter.environmentId)), - (target) => get(terminalSessionBufferAtom(target)), - { - environmentId: filter.environmentId, - ...(filter.threadId !== null ? { threadId: filter.threadId } : {}), - ...(filter.terminalId !== null ? { terminalId: filter.terminalId } : {}), - }, - ), - ).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:known:${JSON.stringify(filter)}`)), -); - -export const runningTerminalIdsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => - Atom.make((get) => { - return pipe( - Object.values(get(terminalSessionMetadataAtom(filter.environmentId))), - Arr.filterMap((entry) => - entry.target.environmentId === filter.environmentId && - (filter.threadId === null || entry.target.threadId === filter.threadId) && - (filter.terminalId === null || entry.target.terminalId === filter.terminalId) && - entry.summary.hasRunningSubprocess - ? Result.succeed(entry.target.terminalId) - : Result.failVoid, - ), - Arr.sort(Order.String), - ); - }).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:running-terminal-ids:${JSON.stringify(filter)}`), - ), -); - -export function createTerminalSessionManager(config: TerminalSessionManagerConfig) { - const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; - - function getMetadata(environmentId: EnvironmentId): Record { - return config.getRegistry().get(terminalSessionMetadataAtom(environmentId)); - } - - function setMetadata( - environmentId: EnvironmentId, - next: Record, - ): void { - config.getRegistry().set(terminalSessionMetadataAtom(environmentId), next); - } - - function getBuffer(target: KnownTerminalSessionTarget): TerminalBufferState { - return config.getRegistry().get(terminalSessionBufferAtom(target)); - } - - function setBuffer(target: KnownTerminalSessionTarget, next: TerminalBufferState): void { - config.getRegistry().set(terminalSessionBufferAtom(target), next); - } - - function getSnapshot(target: TerminalSessionTarget): TerminalSessionState { - const knownTarget = getKnownTerminalSessionTarget(target); - if (knownTarget === null) { - return EMPTY_TERMINAL_SESSION_STATE; - } - - return combineSessionState( - getMetadata(knownTarget.environmentId)[keyFromKnownTarget(knownTarget)]?.summary ?? null, - getBuffer(knownTarget), - ); - } - - function syncSnapshot( - target: Pick, - snapshot: TerminalSessionSnapshot, - ): void { - const knownTarget = getKnownTerminalSessionTarget({ - environmentId: target.environmentId, - threadId: ThreadId.make(snapshot.threadId), - terminalId: snapshot.terminalId, - }); - if (knownTarget === null) { - return; - } - - setBuffer(knownTarget, bufferFromSnapshot(snapshot, maxBufferBytes)); - } - - function applyMetadataEvent( - target: Pick, - event: TerminalMetadataStreamEvent, - ): void { - const environmentId = target.environmentId; - if (environmentId === null) { - return; - } - - if (event.type === "snapshot") { - const retainedKeys = new Set(); - const next = { ...getMetadata(environmentId) }; - - for (const terminal of event.terminals) { - const knownTarget = knownTargetFromSummary(environmentId, terminal); - const targetKey = keyFromKnownTarget(knownTarget); - retainedKeys.add(targetKey); - next[targetKey] = { - target: knownTarget, - summary: terminal, - }; - } - - for (const key of Object.keys(next)) { - if (!retainedKeys.has(key)) { - delete next[key]; - } - } - - setMetadata(environmentId, next); - return; - } - - if (event.type === "upsert") { - const knownTarget = knownTargetFromSummary(environmentId, event.terminal); - const targetKey = keyFromKnownTarget(knownTarget); - setMetadata(environmentId, { - ...getMetadata(environmentId), - [targetKey]: { - target: knownTarget, - summary: event.terminal, - }, - }); - return; - } - - const knownTarget = getKnownTerminalSessionTarget({ - environmentId, - threadId: ThreadId.make(event.threadId), - terminalId: event.terminalId, - }); - if (knownTarget === null) { - return; - } - - const next = { ...getMetadata(environmentId) }; - delete next[keyFromKnownTarget(knownTarget)]; - setMetadata(environmentId, next); - } - - function applyAttachEvent( - target: Pick, - event: TerminalAttachStreamEvent, - ): void { - if (event.type === "snapshot") { - syncSnapshot(target, event.snapshot); - return; - } - - const knownTarget = getKnownTerminalSessionTarget({ - environmentId: target.environmentId, - threadId: ThreadId.make(event.threadId), - terminalId: event.terminalId, - }); - if (knownTarget === null) { - return; - } - - const current = getBuffer(knownTarget); - switch (event.type) { - case "restarted": - setBuffer(knownTarget, bufferFromSnapshot(event.snapshot, maxBufferBytes)); - return; - case "output": - setBuffer(knownTarget, { - ...current, - buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), - status: current.status === "closed" ? "running" : current.status, - error: null, - version: current.version + 1, - }); - return; - case "cleared": - setBuffer(knownTarget, { - ...current, - buffer: "", - error: null, - version: current.version + 1, - }); - return; - case "exited": - setBuffer(knownTarget, { - ...current, - status: "exited", - error: null, - version: current.version + 1, - }); - return; - case "closed": - setBuffer(knownTarget, { - ...current, - status: "closed", - error: null, - version: current.version + 1, - }); - return; - case "error": - setBuffer(knownTarget, { - ...current, - status: "error", - error: event.message, - version: current.version + 1, - }); - return; - case "activity": - return; - } - } - - function invalidate(target?: TerminalSessionTarget): void { - if (target) { - const knownTarget = getKnownTerminalSessionTarget(target); - if (knownTarget !== null) { - const targetKey = keyFromKnownTarget(knownTarget); - const next = { ...getMetadata(knownTarget.environmentId) }; - delete next[targetKey]; - setMetadata(knownTarget.environmentId, next); - setBuffer(knownTarget, EMPTY_TERMINAL_BUFFER_STATE); - } - return; - } - - for (const environmentId of knownTerminalMetadataEnvironmentIds) { - setMetadata(environmentId, {}); - } - knownTerminalMetadataEnvironmentIds.clear(); - for (const target of knownTerminalBufferTargets.values()) { - setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); - } - knownTerminalBufferTargets.clear(); - } - - function invalidateEnvironment(environmentId: EnvironmentId): void { - setMetadata(environmentId, {}); - knownTerminalMetadataEnvironmentIds.delete(environmentId); - - const prefix = `${environmentId}:`; - for (const [key, target] of knownTerminalBufferTargets) { - if (key.startsWith(prefix)) { - setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); - } - } - } - - function reset(): void { - invalidate(); - } - - function listSessions( - filter?: Partial, - ): ReadonlyArray { - if (filter?.environmentId) { - return listKnownSessionsFromMetadata(getMetadata(filter.environmentId), getBuffer, filter); - } - - return pipe( - knownTerminalMetadataEnvironmentIds, - Arr.fromIterable, - Arr.flatMap((environmentId) => - listKnownSessionsFromMetadata(getMetadata(environmentId), getBuffer, filter), - ), - ); - } - - function subscribeMetadata(input: { - readonly environmentId: EnvironmentId; - readonly client: TerminalMetadataClient; - readonly options?: { readonly onResubscribe?: () => void }; - }): () => void { - return input.client.terminal.onMetadata( - (event) => applyMetadataEvent({ environmentId: input.environmentId }, event), - input.options, - ); - } - - function attach(input: { - readonly environmentId: EnvironmentId; - readonly client: TerminalAttachClient; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: (event: TerminalAttachStreamEvent) => void; - readonly options?: { readonly onResubscribe?: () => void }; - }): () => void { - return input.client.terminal.attach( - input.terminal, - (event) => { - applyAttachEvent({ environmentId: input.environmentId }, event); - input.onEvent?.(event); - if (event.type === "snapshot") { - input.onSnapshot?.(event.snapshot); - } - }, - input.options, - ); - } - - return { - attach, - getSnapshot, - invalidate, - invalidateEnvironment, - listSessions, - subscribeMetadata, - reset, - }; -} diff --git a/packages/client-runtime/src/threadDetailState.test.ts b/packages/client-runtime/src/threadDetailState.test.ts deleted file mode 100644 index 0dae16f2270..00000000000 --- a/packages/client-runtime/src/threadDetailState.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - EventId, - EnvironmentId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationThread, - type OrchestrationThreadStreamItem, -} from "@t3tools/contracts"; - -import { createThreadDetailManager, type ThreadDetailClient } from "./threadDetailState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const baseEventFields = { - eventId: EventId.make("event-1"), - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, -} as const; - -const BASE_THREAD: OrchestrationThread = { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Test Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - goal: null, -}; - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), -} as const; - -function createMockClient(): { - client: ThreadDetailClient; - listeners: Set<(event: OrchestrationThreadStreamItem) => void>; - emit: (event: OrchestrationThreadStreamItem) => void; -} { - const listeners = new Set<(event: OrchestrationThreadStreamItem) => void>(); - const client: ThreadDetailClient = { - subscribeThread: vi.fn((_input, listener: (event: OrchestrationThreadStreamItem) => void) => - registerListener(listeners, listener), - ), - }; - - return { - client, - listeners, - emit: (event) => { - for (const listener of listeners) { - listener(event); - } - }, - }; -} - -describe("createThreadDetailManager", () => { - afterEach(() => { - vi.useRealTimers(); - resetAtomRegistry(); - }); - - it("starts in a pending state when watching", () => { - const { client } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - manager.watch(TARGET, client); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: true, - isDeleted: false, - }); - }); - - it("applies snapshots and incremental events", () => { - const { client, emit } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - - emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - - emit({ - kind: "event", - event: { - ...baseEventFields, - sequence: 2, - occurredAt: "2026-04-01T01:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - turnId: TurnId.make("turn-1"), - messageId: MessageId.make("message-1"), - role: "assistant", - text: "hello", - streaming: false, - createdAt: "2026-04-01T01:00:00.000Z", - updatedAt: "2026-04-01T01:00:00.000Z", - }, - } as any, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: { - ...BASE_THREAD, - updatedAt: "2026-04-01T01:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-01T01:00:00.000Z", - startedAt: "2026-04-01T01:00:00.000Z", - completedAt: "2026-04-01T01:00:00.000Z", - assistantMessageId: MessageId.make("message-1"), - }, - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "hello", - turnId: TurnId.make("turn-1"), - streaming: false, - createdAt: "2026-04-01T01:00:00.000Z", - updatedAt: "2026-04-01T01:00:00.000Z", - }, - ], - }, - error: null, - isPending: false, - isDeleted: false, - }); - - release(); - }); - - it("marks threads as deleted when the stream deletes them", () => { - const { client, emit } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - - emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - emit({ - kind: "event", - event: { - ...baseEventFields, - sequence: 3, - occurredAt: "2026-04-01T01:10:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.deleted", - payload: { - threadId: ThreadId.make("thread-1"), - deletedAt: "2026-04-01T01:10:00.000Z", - }, - } as any, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: false, - isDeleted: true, - }); - - release(); - }); - - it("waits for delayed client registration when subscribeClientChanges is configured", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: (environmentId) => clients.get(environmentId)?.client ?? null, - getClientIdentity: (environmentId) => (clients.has(environmentId) ? environmentId : null), - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET).isPending).toBe(true); - - const mock = createMockClient(); - clients.set("env-local", mock); - for (const listener of connectionListeners) { - listener(); - } - - mock.emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - - expect(manager.getSnapshot(TARGET).data?.id).toBe(ThreadId.make("thread-1")); - - release(); - }); - - it("evicts idle subscriptions after the configured ttl", () => { - vi.useFakeTimers(); - const mock = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - retention: { - idleTtlMs: 60_000, - maxRetainedEntries: 10, - }, - }); - - const release = manager.watch(TARGET); - expect(mock.listeners.size).toBe(1); - - release(); - expect(mock.listeners.size).toBe(1); - - vi.advanceTimersByTime(60_000); - expect(mock.listeners.size).toBe(0); - }); - - it("keeps non-idle threads warm when the retention policy says to", () => { - vi.useFakeTimers(); - const mock = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - retention: { - idleTtlMs: 60_000, - maxRetainedEntries: 10, - shouldKeepWarm: (_target, state) => state.data?.session?.status === "running", - }, - }); - - const release = manager.watch(TARGET); - mock.emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: { - ...BASE_THREAD, - session: { - threadId: ThreadId.make("thread-1"), - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-1"), - lastError: null, - updatedAt: "2026-04-01T00:10:00.000Z", - }, - }, - }, - }); - - release(); - vi.advanceTimersByTime(60_000); - - expect(mock.listeners.size).toBe(1); - manager.reset(); - expect(mock.listeners.size).toBe(0); - }); -}); diff --git a/packages/client-runtime/src/threadDetailState.ts b/packages/client-runtime/src/threadDetailState.ts deleted file mode 100644 index d8c5cc4add4..00000000000 --- a/packages/client-runtime/src/threadDetailState.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; -import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import type { - OrchestrationThread, - OrchestrationThreadStreamItem, - EnvironmentId, - ThreadId as ThreadIdType, -} from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { - DEFAULT_THREAD_DETAIL_LIMITS, - applyThreadDetailEvent, - type ThreadDetailRetentionLimits, -} from "./threadDetailReducer.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface ThreadDetailState { - readonly data: OrchestrationThread | null; - readonly error: string | null; - readonly isPending: boolean; - readonly isDeleted: boolean; -} - -export interface ThreadDetailTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadIdType | null; -} - -export type ThreadDetailClient = Pick; - -export interface ThreadDetailRetentionPolicy { - readonly idleTtlMs: number; - readonly maxRetainedEntries: number; - readonly shouldKeepWarm?: ( - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - state: ThreadDetailState, - ) => boolean; -} - -interface ThreadDetailEntry { - readonly target: { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadIdType; - }; - watcherCount: number; - retainCount: number; - teardown: () => void; - lastAccessedAt: number; - evictionFiber: Fiber.Fiber | null; -} - -const NOOP: () => void = () => undefined; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -function clearEntryEviction(entry: ThreadDetailEntry): void { - if (entry.evictionFiber !== null) { - Effect.runFork(Fiber.interrupt(entry.evictionFiber)); - entry.evictionFiber = null; - } -} - -export const EMPTY_THREAD_DETAIL_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, - isDeleted: false, -}); - -const INITIAL_THREAD_DETAIL_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, - isDeleted: false, -}); - -const knownThreadDetailKeys = new Set(); - -export const threadDetailStateAtom = Atom.family((key: string) => { - knownThreadDetailKeys.add(key); - return Atom.make(INITIAL_THREAD_DETAIL_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`thread-detail:${key}`), - ); -}); - -export const EMPTY_THREAD_DETAIL_ATOM = Atom.make(EMPTY_THREAD_DETAIL_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("thread-detail:null"), -); - -export function getThreadDetailTargetKey(target: ThreadDetailTarget): string | null { - if (target.environmentId === null || target.threadId === null) { - return null; - } - - return `${target.environmentId}:${target.threadId}`; -} - -export interface ThreadDetailManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ThreadDetailClient | null; - readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly limits?: ThreadDetailRetentionLimits; - readonly retention?: ThreadDetailRetentionPolicy; -} - -export function createThreadDetailManager(config: ThreadDetailManagerConfig) { - const entries = new Map(); - - function getSnapshot(target: ThreadDetailTarget): ThreadDetailState { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey === null) { - return EMPTY_THREAD_DETAIL_STATE; - } - - return config.getRegistry().get(threadDetailStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: ThreadDetailState): void { - config.getRegistry().set(threadDetailStateAtom(targetKey), nextState); - reconcileRetention(targetKey); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(threadDetailStateAtom(targetKey)); - setState(targetKey, { - ...current, - error: null, - isPending: true, - }); - } - - function setData(targetKey: string, thread: OrchestrationThread): void { - setState(targetKey, { - data: thread, - error: null, - isPending: false, - isDeleted: false, - }); - } - - function setDeleted(targetKey: string): void { - setState(targetKey, { - data: null, - error: null, - isPending: false, - isDeleted: true, - }); - } - - function shouldKeepWarm(entry: ThreadDetailEntry): boolean { - return config.retention?.shouldKeepWarm?.(entry.target, getSnapshot(entry.target)) ?? false; - } - - function disposeEntry(targetKey: string): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - clearEntryEviction(entry); - entry.teardown(); - entries.delete(targetKey); - } - - function evictIdleEntriesToCapacity(): void { - const retention = config.retention; - if (!retention || entries.size <= retention.maxRetainedEntries) { - return; - } - - const idleEntries = pipe( - Arr.fromIterable(entries), - Arr.filter( - ([, entry]) => - entry.watcherCount === 0 && entry.retainCount === 0 && !shouldKeepWarm(entry), - ), - Arr.sortWith(([, e]) => e.lastAccessedAt, Order.Number), - ); - - for (const [targetKey] of idleEntries) { - if (entries.size <= retention.maxRetainedEntries) { - return; - } - disposeEntry(targetKey); - } - } - - function scheduleEviction(targetKey: string, entry: ThreadDetailEntry): void { - const retention = config.retention; - clearEntryEviction(entry); - - if (!retention) { - disposeEntry(targetKey); - return; - } - - if (retention.idleTtlMs <= 0) { - disposeEntry(targetKey); - return; - } - - entry.evictionFiber = Effect.runFork( - Effect.sleep(Duration.millis(retention.idleTtlMs)).pipe( - Effect.andThen( - Effect.sync(() => { - const current = entries.get(targetKey); - if (!current) { - return; - } - - current.evictionFiber = null; - if (current.watcherCount > 0 || current.retainCount > 0 || shouldKeepWarm(current)) { - return; - } - - disposeEntry(targetKey); - }), - ), - ), - ); - } - - function reconcileRetention(targetKey: string): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - clearEntryEviction(entry); - if (entry.watcherCount > 0 || entry.retainCount > 0 || shouldKeepWarm(entry)) { - return; - } - - scheduleEviction(targetKey, entry); - evictIdleEntriesToCapacity(); - } - - function applyStreamItem( - targetKey: string, - item: OrchestrationThreadStreamItem, - threadId: ThreadIdType, - ): void { - if (item.kind === "snapshot") { - setData(targetKey, item.snapshot.thread); - return; - } - - const current = getSnapshot({ - environmentId: entries.get(targetKey)?.target.environmentId ?? null, - threadId, - }).data; - - if (current === null) { - if (item.event.type === "thread.deleted") { - setDeleted(targetKey); - } - return; - } - - const result = applyThreadDetailEvent( - current, - item.event, - config.limits ?? DEFAULT_THREAD_DETAIL_LIMITS, - ); - - if (result.kind === "updated") { - setData(targetKey, result.thread); - return; - } - - if (result.kind === "deleted") { - setDeleted(targetKey); - } - } - - function subscribeStream( - targetKey: string, - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - client: ThreadDetailClient, - ): () => void { - markPending(targetKey); - return client.subscribeThread( - { threadId: target.threadId }, - (item) => applyStreamItem(targetKey, item, target.threadId), - { - onResubscribe: () => markPending(targetKey), - }, - ); - } - - function createDynamicSubscription( - targetKey: string, - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - ): () => void { - let currentIdentity: string | null = null; - let currentUnsub = NOOP; - - const sync = () => { - const client = config.getClient(target.environmentId); - const identity = client - ? (config.getClientIdentity?.(target.environmentId) ?? target.environmentId) - : null; - - if (!client || identity === null) { - if (currentIdentity !== null) { - currentUnsub(); - currentUnsub = NOOP; - currentIdentity = null; - } - markPending(targetKey); - return; - } - - if (currentIdentity === identity) { - return; - } - - currentUnsub(); - currentIdentity = identity; - currentUnsub = subscribeStream(targetKey, target, client); - }; - - const unsubChanges = config.subscribeClientChanges!(sync); - sync(); - - return () => { - unsubChanges(); - currentUnsub(); - }; - } - - function acquire( - target: ThreadDetailTarget, - kind: "watcher" | "retain", - client?: ThreadDetailClient, - ): () => void { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey === null || target.environmentId === null || target.threadId === null) { - return NOOP; - } - - const existing = entries.get(targetKey); - if (existing) { - clearEntryEviction(existing); - existing.lastAccessedAt = nowMs(); - if (kind === "watcher") { - existing.watcherCount += 1; - } else { - existing.retainCount += 1; - } - return () => release(targetKey, kind); - } - - let teardown: () => void; - const resolvedTarget = { - environmentId: target.environmentId, - threadId: target.threadId, - }; - - if (client) { - teardown = subscribeStream(targetKey, resolvedTarget, client); - } else if (config.subscribeClientChanges) { - teardown = createDynamicSubscription(targetKey, resolvedTarget); - } else { - const resolved = config.getClient(target.environmentId); - if (!resolved) { - return NOOP; - } - teardown = subscribeStream(targetKey, resolvedTarget, resolved); - } - - entries.set(targetKey, { - target: resolvedTarget, - watcherCount: kind === "watcher" ? 1 : 0, - retainCount: kind === "retain" ? 1 : 0, - teardown, - lastAccessedAt: nowMs(), - evictionFiber: null, - }); - evictIdleEntriesToCapacity(); - return () => release(targetKey, kind); - } - - function release(targetKey: string, kind: "watcher" | "retain"): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - if (kind === "watcher") { - entry.watcherCount = Math.max(0, entry.watcherCount - 1); - } else { - entry.retainCount = Math.max(0, entry.retainCount - 1); - } - entry.lastAccessedAt = nowMs(); - reconcileRetention(targetKey); - } - - function watch(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { - return acquire(target, "watcher", client); - } - - function retain(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { - return acquire(target, "retain", client); - } - - function invalidate(target?: ThreadDetailTarget): void { - if (target) { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey !== null) { - disposeEntry(targetKey); - config.getRegistry().set(threadDetailStateAtom(targetKey), EMPTY_THREAD_DETAIL_STATE); - } - return; - } - - for (const targetKey of entries.keys()) { - disposeEntry(targetKey); - } - for (const key of knownThreadDetailKeys) { - config.getRegistry().set(threadDetailStateAtom(key), EMPTY_THREAD_DETAIL_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - watch, - retain, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsActionState.test.ts b/packages/client-runtime/src/vcsActionState.test.ts deleted file mode 100644 index f653b26b34f..00000000000 --- a/packages/client-runtime/src/vcsActionState.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - EnvironmentId, - type GitActionProgressEvent, - type GitRunStackedActionResult, - type VcsCreateRefResult, - type VcsCreateWorktreeResult, - type VcsPullResult, - type VcsStatusResult, - type VcsSwitchRefResult, -} from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type VcsActionClient, - createVcsActionManager, - EMPTY_VCS_ACTION_STATE, -} from "./vcsActionState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; - -const BASE_STATUS: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/test", - hasWorkingTreeChanges: true, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -function createPhaseStartedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "phase_started", - phase: "commit", - label: "Committing...", - }; -} - -function createHookStartedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_started", - hookName: "post-commit", - }; -} - -function createHookOutputEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_output", - hookName: "post-commit", - stream: "stdout", - text: "hook output", - }; -} - -function createHookFinishedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_finished", - hookName: "post-commit", - exitCode: 0, - durationMs: 12, - }; -} - -function createActionFinishedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "action_finished", - result: { - action: "commit_push", - branch: { status: "skipped_not_requested" }, - commit: { status: "created", commitSha: "abc123", subject: "Test commit" }, - push: { - status: "pushed", - branch: "feature/test", - upstreamBranch: "origin/feature/test", - }, - pr: { status: "skipped_not_requested" }, - toast: { - title: "Done", - description: "Action finished", - cta: { kind: "none" }, - }, - } satisfies GitRunStackedActionResult, - }; -} - -function createMockClient() { - const refreshDeferred = createDeferred(); - const pullDeferred = createDeferred(); - const switchRefDeferred = createDeferred(); - const createRefDeferred = createDeferred(); - const createWorktreeDeferred = createDeferred(); - const initDeferred = createDeferred(); - const runChangeRequestDeferred = createDeferred(); - let runChangeRequestProgressListener: ((event: GitActionProgressEvent) => void) | null = null; - - const client: VcsActionClient = { - refreshStatus: vi.fn(() => refreshDeferred.promise), - pull: vi.fn(() => pullDeferred.promise), - switchRef: vi.fn(() => switchRefDeferred.promise), - createRef: vi.fn(() => createRefDeferred.promise), - createWorktree: vi.fn(() => createWorktreeDeferred.promise), - init: vi.fn(() => initDeferred.promise), - runChangeRequest: vi.fn((_, options) => { - runChangeRequestProgressListener = options?.onProgress ?? null; - return runChangeRequestDeferred.promise; - }), - }; - - return { - client, - refreshDeferred, - pullDeferred, - switchRefDeferred, - createRefDeferred, - createWorktreeDeferred, - initDeferred, - runChangeRequestDeferred, - emitProgress(event: GitActionProgressEvent) { - runChangeRequestProgressListener?.(event); - }, - }; -} - -describe("createVcsActionManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("tracks refreshStatus progress and clears state on success", async () => { - const mock = createMockClient(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.refreshStatus(TARGET, mock.client); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: true, - operation: "refresh_status", - currentLabel: "Refreshing source control status", - error: null, - }); - - mock.refreshDeferred.resolve(BASE_STATUS); - - await expect(promise).resolves.toEqual(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); - - it("tracks runChangeRequest progress events", async () => { - const mock = createMockClient(); - const onProgress = vi.fn(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - getActionId: () => "action-123", - }); - - const promise = manager.runChangeRequest( - TARGET, - { action: "commit_push", commitMessage: "Test commit" }, - { client: mock.client, gitStatus: BASE_STATUS, onProgress }, - ); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: true, - operation: "run_change_request", - actionId: "action-123", - currentLabel: "Committing...", - error: null, - }); - - mock.emitProgress(createPhaseStartedEvent()); - expect(manager.getSnapshot(TARGET).currentLabel).toBe("Committing..."); - expect(onProgress).toHaveBeenLastCalledWith(createPhaseStartedEvent()); - - mock.emitProgress(createHookStartedEvent()); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - currentLabel: "Running post-commit...", - hookName: "post-commit", - isRunning: true, - }); - - mock.emitProgress(createHookOutputEvent()); - expect(manager.getSnapshot(TARGET).lastOutputLine).toBe("hook output"); - - mock.emitProgress(createHookFinishedEvent()); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - currentLabel: "Committing...", - hookName: null, - lastOutputLine: null, - }); - - const result = createActionFinishedEvent().result; - mock.runChangeRequestDeferred.resolve(result); - - await expect(promise).resolves.toEqual(result); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); - - it("stores the error when an operation fails", async () => { - const mock = createMockClient(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.pull(TARGET, mock.client); - - mock.pullDeferred.reject(new Error("Pull failed.")); - - await expect(promise).rejects.toThrow("Pull failed."); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: false, - operation: "pull", - currentLabel: null, - error: "Pull failed.", - }); - }); - - it("invalidates after successful mutations but not refreshStatus", async () => { - const mock = createMockClient(); - const onInvalidate = vi.fn(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - onInvalidate, - }); - - const refreshPromise = manager.refreshStatus(TARGET, mock.client); - mock.refreshDeferred.resolve(BASE_STATUS); - await expect(refreshPromise).resolves.toEqual(BASE_STATUS); - expect(onInvalidate).not.toHaveBeenCalled(); - - const pullPromise = manager.pull(TARGET, mock.client); - const pullResult: VcsPullResult = { - status: "skipped_up_to_date", - refName: "main", - upstreamRef: null, - }; - mock.pullDeferred.resolve(pullResult); - await expect(pullPromise).resolves.toEqual(pullResult); - expect(onInvalidate).toHaveBeenCalledWith(TARGET); - }); - - it("returns null when no client is available", async () => { - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - await expect(manager.switchRef(TARGET, { refName: "main" })).resolves.toBeNull(); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); -}); diff --git a/packages/client-runtime/src/vcsActionState.ts b/packages/client-runtime/src/vcsActionState.ts deleted file mode 100644 index 5ff545b4596..00000000000 --- a/packages/client-runtime/src/vcsActionState.ts +++ /dev/null @@ -1,458 +0,0 @@ -import type { - GitActionProgressEvent, - GitRunStackedActionInput, - GitRunStackedActionResult, - GitStackedAction, - EnvironmentId, - VcsCreateRefInput, - VcsCreateRefResult, - VcsCreateWorktreeInput, - VcsCreateWorktreeResult, - VcsPullInput, - VcsPullResult, - VcsStatusResult, - VcsSwitchRefInput, - VcsSwitchRefResult, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { buildGitActionProgressStages } from "./gitActions.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export type VcsActionOperation = - | "refresh_status" - | "run_change_request" - | "pull" - | "switch_ref" - | "create_ref" - | "create_worktree" - | "init"; - -export interface VcsActionState { - readonly isRunning: boolean; - readonly operation: VcsActionOperation | null; - readonly actionId: string | null; - readonly action: GitStackedAction | null; - readonly currentLabel: string | null; - readonly currentPhaseLabel: string | null; - readonly hookName: string | null; - readonly lastOutputLine: string | null; - readonly phaseStartedAtMs: number | null; - readonly hookStartedAtMs: number | null; - readonly error: string | null; -} - -export interface VcsActionTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export type VcsActionClient = Pick< - WsRpcClient["vcs"], - "refreshStatus" | "pull" | "switchRef" | "createRef" | "createWorktree" | "init" -> & { - readonly runChangeRequest: WsRpcClient["git"]["runStackedAction"]; -}; - -export const EMPTY_VCS_ACTION_STATE = Object.freeze({ - isRunning: false, - operation: null, - actionId: null, - action: null, - currentLabel: null, - currentPhaseLabel: null, - hookName: null, - lastOutputLine: null, - phaseStartedAtMs: null, - hookStartedAtMs: null, - error: null, -}); - -const knownVcsActionKeys = new Set(); -let nextGeneratedActionId = 0; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -export const vcsActionStateAtom = Atom.family((key: string) => { - knownVcsActionKeys.add(key); - return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`vcs-action:${key}`), - ); -}); - -export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-action:null"), -); - -export function getVcsActionTargetKey(target: VcsActionTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - return `${target.environmentId}:${target.cwd}`; -} - -export function applyVcsActionProgressEvent( - current: VcsActionState, - event: GitActionProgressEvent, -): VcsActionState { - const now = nowMs(); - - switch (event.kind) { - case "action_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - phaseStartedAtMs: now, - hookStartedAtMs: null, - hookName: null, - lastOutputLine: null, - error: null, - }; - case "phase_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: event.label, - currentPhaseLabel: event.label, - phaseStartedAtMs: now, - hookStartedAtMs: null, - hookName: null, - lastOutputLine: null, - error: null, - }; - case "hook_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: `Running ${event.hookName}...`, - hookName: event.hookName, - hookStartedAtMs: now, - lastOutputLine: null, - error: null, - }; - case "hook_output": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - lastOutputLine: event.text, - error: null, - }; - case "hook_finished": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: current.currentPhaseLabel, - hookName: null, - hookStartedAtMs: null, - lastOutputLine: null, - error: null, - }; - case "action_finished": - return { - ...current, - isRunning: false, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - error: null, - }; - case "action_failed": - return { - ...EMPTY_VCS_ACTION_STATE, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - error: event.message, - }; - } -} - -export interface VcsActionManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => VcsActionClient | null; - readonly getActionId?: () => string; - readonly onInvalidate?: (target: VcsActionTarget) => void | Promise; -} - -export function createVcsActionManager(config: VcsActionManagerConfig) { - function setState(targetKey: string, nextState: VcsActionState): void { - config.getRegistry().set(vcsActionStateAtom(targetKey), nextState); - } - - function startOperation( - targetKey: string, - input: { - readonly operation: VcsActionOperation; - readonly actionId?: string; - readonly action?: GitStackedAction; - readonly label: string; - }, - ): void { - setState(targetKey, { - isRunning: true, - operation: input.operation, - actionId: input.actionId ?? null, - action: input.action ?? null, - currentLabel: input.label, - currentPhaseLabel: input.label, - hookName: null, - lastOutputLine: null, - phaseStartedAtMs: nowMs(), - hookStartedAtMs: null, - error: null, - }); - } - - function finishOperation(targetKey: string): void { - setState(targetKey, EMPTY_VCS_ACTION_STATE); - } - - function failOperation( - targetKey: string, - error: unknown, - input: { - readonly operation: VcsActionOperation; - readonly actionId?: string; - readonly action?: GitStackedAction; - }, - ): void { - setState(targetKey, { - ...EMPTY_VCS_ACTION_STATE, - operation: input.operation, - actionId: input.actionId ?? null, - action: input.action ?? null, - error: error instanceof Error ? error.message : "Source control action failed.", - }); - } - - async function runOperation( - target: VcsActionTarget, - input: { - readonly operation: VcsActionOperation; - readonly label: string; - readonly actionId?: string; - readonly action?: GitStackedAction; - readonly client?: VcsActionClient | undefined; - readonly invalidateOnSuccess?: boolean; - readonly execute: (client: VcsActionClient) => Promise; - }, - ): Promise { - const targetKey = getVcsActionTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return null; - } - - const resolved = input.client ?? config.getClient(target.environmentId); - if (!resolved) { - return null; - } - - startOperation(targetKey, input); - try { - const result = await input.execute(resolved); - finishOperation(targetKey); - if (input.invalidateOnSuccess ?? true) { - await config.onInvalidate?.(target); - } - return result; - } catch (error) { - failOperation(targetKey, error, input); - throw error; - } - } - - function getSnapshot(target: VcsActionTarget): VcsActionState { - const targetKey = getVcsActionTargetKey(target); - if (targetKey === null) { - return EMPTY_VCS_ACTION_STATE; - } - - return config.getRegistry().get(vcsActionStateAtom(targetKey)); - } - - async function refreshStatus( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly quiet?: boolean }, - ): Promise> | null> { - if (options?.quiet) { - if (target.environmentId === null || target.cwd === null) { - return null; - } - const resolved = client ?? config.getClient(target.environmentId); - return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null; - } - - return runOperation(target, { - operation: "refresh_status", - label: "Refreshing source control status", - client, - invalidateOnSuccess: false, - execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }), - }); - } - - async function pull( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "pull", - label: options?.label ?? "Pulling latest changes", - client, - execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies VcsPullInput), - }); - } - - async function switchRef( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "switch_ref", - label: options?.label ?? "Switching branch", - client, - execute: (resolved) => resolved.switchRef({ cwd: target.cwd!, ...input }), - }); - } - - async function createRef( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "create_ref", - label: options?.label ?? "Creating branch", - client, - execute: (resolved) => resolved.createRef({ cwd: target.cwd!, ...input }), - }); - } - - async function createWorktree( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "create_worktree", - label: options?.label ?? "Creating worktree", - client, - execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }), - }); - } - - async function init( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise> | null> { - return runOperation(target, { - operation: "init", - label: options?.label ?? "Initializing repository", - client, - execute: (resolved) => resolved.init({ cwd: target.cwd! }), - }); - } - - async function runChangeRequest( - target: VcsActionTarget, - input: Omit & { readonly actionId?: string }, - options?: { - readonly client?: VcsActionClient; - readonly gitStatus?: VcsStatusResult | null; - readonly onProgress?: (event: GitActionProgressEvent) => void; - }, - ): Promise { - const actionId = - input.actionId ?? - config.getActionId?.() ?? - `vcs-action-${nowMs()}-${++nextGeneratedActionId}`; - const targetKey = getVcsActionTargetKey(target); - - return runOperation(target, { - operation: "run_change_request", - label: - buildGitActionProgressStages({ - action: input.action, - hasCustomCommitMessage: Boolean(input.commitMessage?.trim()), - hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false, - featureBranch: input.featureBranch ?? false, - shouldPushBeforePr: - input.action === "create_pr" && - (!(options?.gitStatus?.hasUpstream ?? false) || - (options?.gitStatus?.aheadCount ?? 0) > 0), - })[0] ?? "Running source control action", - actionId, - action: input.action, - client: options?.client, - execute: async (resolved) => { - const result = await resolved.runChangeRequest( - { - cwd: target.cwd!, - actionId, - ...input, - }, - { - onProgress: (event) => { - if (targetKey !== null) { - const current = getSnapshot(target); - setState(targetKey, applyVcsActionProgressEvent(current, event)); - } - options?.onProgress?.(event); - }, - }, - ); - return result; - }, - }); - } - - function reset(target?: VcsActionTarget): void { - if (target) { - const targetKey = getVcsActionTargetKey(target); - if (targetKey !== null) { - setState(targetKey, EMPTY_VCS_ACTION_STATE); - } - return; - } - - for (const key of knownVcsActionKeys) { - setState(key, EMPTY_VCS_ACTION_STATE); - } - } - - return { - getSnapshot, - refreshStatus, - pull, - switchRef, - createRef, - createWorktree, - init, - runChangeRequest, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsRefState.test.ts b/packages/client-runtime/src/vcsRefState.test.ts deleted file mode 100644 index 3e58c0b5ac0..00000000000 --- a/packages/client-runtime/src/vcsRefState.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - createVcsRefManager, - EMPTY_VCS_REF_STATE, - vcsRefStateAtom, - type VcsRefClient, -} from "./vcsRefState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const noop = () => undefined; - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; - -const FIRST_PAGE: VcsListRefsResult = { - refs: [ - { name: "main", current: true, isDefault: true, worktreePath: null }, - { name: "feature/a", current: false, isDefault: false, worktreePath: null }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: 2, - totalCount: 3, -}; - -const SECOND_PAGE: VcsListRefsResult = { - refs: [{ name: "feature/b", current: false, isDefault: false, worktreePath: null }], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 3, -}; - -function createMockClient() { - const listRefs = vi.fn(async (input: Parameters[0]) => { - if (input.query === "feature") { - return { - ...FIRST_PAGE, - refs: FIRST_PAGE.refs.filter((branch) => branch.name.includes("feature")), - nextCursor: null, - totalCount: 2, - } satisfies VcsListRefsResult; - } - - if (input.cursor === 2) { - return SECOND_PAGE; - } - - return FIRST_PAGE; - }); - - return { - client: { listRefs } satisfies VcsRefClient, - listRefs, - }; -} - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -describe("createVcsRefManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads the first page and stores it in atom state", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.load(TARGET, mock.client, { limit: 100 }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - isPending: true, - error: null, - }); - - await expect(promise).resolves.toEqual(FIRST_PAGE); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: false, - error: null, - }); - expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); - }); - - it("loads the next page and appends refs", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - await manager.load(TARGET, mock.client); - const next = await manager.loadNext(TARGET, mock.client); - - expect(next).toEqual({ - ...SECOND_PAGE, - refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], - }); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: { - ...SECOND_PAGE, - refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], - }, - isPending: false, - error: null, - }); - }); - - it("keeps cached refs visible while refreshing", async () => { - const nextLoad = deferred(); - let callCount = 0; - const listRefs = vi.fn((async () => { - callCount += 1; - return callCount === 1 ? FIRST_PAGE : nextLoad.promise; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - await manager.load(TARGET, client); - - const refresh = manager.load(TARGET, client); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: true, - error: null, - }); - - nextLoad.resolve(SECOND_PAGE); - await expect(refresh).resolves.toEqual(SECOND_PAGE); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: SECOND_PAGE, - isPending: false, - error: null, - }); - }); - - it("preserves loaded pages during first-page revalidation", async () => { - const refreshedFirstPage: VcsListRefsResult = { - ...FIRST_PAGE, - refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], - nextCursor: 1, - totalCount: 3, - }; - let callCount = 0; - const listRefs = vi.fn((async (input) => { - callCount += 1; - if (input.cursor === 2) { - return SECOND_PAGE; - } - return callCount === 1 ? FIRST_PAGE : refreshedFirstPage; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - await manager.load(TARGET, client); - await manager.loadNext(TARGET, client); - const beforeRefresh = manager.getSnapshot(TARGET).data; - expect(beforeRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); - - await manager.load(TARGET, client, { preserveLoadedRefs: true }); - - const afterRefresh = manager.getSnapshot(TARGET).data; - expect(afterRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); - expect(afterRefresh?.nextCursor).toBeNull(); - }); - - it("stores query-specific state independently", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const queriedTarget = { ...TARGET, query: "feature" } as const; - const queried = await manager.load(queriedTarget, mock.client); - - expect(queried?.refs.map((branch) => branch.name)).toEqual(["feature/a"]); - expect(manager.getSnapshot(TARGET).data).toBeNull(); - expect(manager.getSnapshot(queriedTarget).data?.refs.map((branch) => branch.name)).toEqual([ - "feature/a", - ]); - }); - - it("returns cached data when no client is available", async () => { - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - atomRegistry.set(vcsRefStateAtom("env-local:/repo:"), { - data: FIRST_PAGE, - isPending: false, - error: null, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(FIRST_PAGE); - }); - - it("resets state", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - await manager.load(TARGET, mock.client); - manager.reset(); - - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); - }); - - it("invalidates every query for a cwd scope", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - const queriedTarget = { ...TARGET, query: "feature" } as const; - - await manager.load(TARGET, mock.client); - await manager.load(queriedTarget, mock.client); - - manager.invalidateScope({ environmentId: TARGET.environmentId, cwd: TARGET.cwd }); - - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); - expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE); - }); - - it("invalidates target in-flight loads before they can write stale data", async () => { - const firstLoad = deferred(); - let callCount = 0; - const listRefs = vi.fn((async () => { - callCount += 1; - return callCount === 1 ? firstLoad.promise : SECOND_PAGE; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - const staleLoad = manager.load(TARGET, client); - manager.invalidate(TARGET); - const freshLoad = manager.load(TARGET, client); - - expect(listRefs).toHaveBeenCalledTimes(2); - - firstLoad.resolve(FIRST_PAGE); - await expect(staleLoad).resolves.toEqual(FIRST_PAGE); - await expect(freshLoad).resolves.toEqual(SECOND_PAGE); - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - }); - - it("watches refs with a ref-counted client-change subscription", async () => { - const mock = createMockClient(); - let listener: () => void = noop; - const unsubscribe = vi.fn(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return unsubscribe; - }, - watchLimit: 100, - }); - - const firstUnwatch = manager.watch(TARGET); - const secondUnwatch = manager.watch(TARGET); - await Promise.resolve(); - - expect(mock.listRefs).toHaveBeenCalledTimes(1); - expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); - - listener(); - await Promise.resolve(); - expect(mock.listRefs).toHaveBeenCalledTimes(1); - - firstUnwatch(); - expect(unsubscribe).not.toHaveBeenCalled(); - secondUnwatch(); - expect(unsubscribe).toHaveBeenCalledTimes(1); - }); - - it("skips watched refresh while cached refs are fresh", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - staleTimeMs: 60_000, - watchLimit: 100, - }); - - const firstUnwatch = manager.watch(TARGET); - await vi.waitFor(() => { - expect(manager.getSnapshot(TARGET).data).toEqual(FIRST_PAGE); - }); - firstUnwatch(); - - const secondUnwatch = manager.watch(TARGET); - await Promise.resolve(); - expect(mock.listRefs).toHaveBeenCalledTimes(1); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: false, - error: null, - }); - - secondUnwatch(); - }); - - it("swallows watched refresh failures after storing error state", async () => { - const refreshError = new Error("backend unavailable"); - const listRefs = vi.fn(async () => { - throw refreshError; - }); - const onBackgroundError = vi.fn(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => ({ listRefs }), - onBackgroundError, - }); - - manager.watch(TARGET); - await Promise.resolve(); - await Promise.resolve(); - - await vi.waitFor(() => { - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - isPending: false, - error: "backend unavailable", - }); - expect(onBackgroundError).toHaveBeenCalledWith(refreshError); - }); - }); - - it("starts a new watched refresh when the client is replaced while a load is in flight", async () => { - const firstLoad = deferred(); - const secondLoad = deferred(); - const firstListRefs = vi.fn(() => firstLoad.promise); - const secondListRefs = vi.fn(() => secondLoad.promise); - const firstClient = { listRefs: firstListRefs } satisfies VcsRefClient; - const secondClient = { listRefs: secondListRefs } satisfies VcsRefClient; - let currentClient: VcsRefClient = firstClient; - let listener: () => void = noop; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => currentClient, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return noop; - }, - }); - - manager.watch(TARGET); - await Promise.resolve(); - expect(firstListRefs).toHaveBeenCalledTimes(1); - - currentClient = secondClient; - listener(); - await Promise.resolve(); - expect(secondListRefs).toHaveBeenCalledTimes(1); - - secondLoad.resolve(SECOND_PAGE); - await secondLoad.promise; - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - - firstLoad.resolve(FIRST_PAGE); - await firstLoad.promise; - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - }); -}); diff --git a/packages/client-runtime/src/vcsRefState.ts b/packages/client-runtime/src/vcsRefState.ts deleted file mode 100644 index e414a5f3de5..00000000000 --- a/packages/client-runtime/src/vcsRefState.ts +++ /dev/null @@ -1,451 +0,0 @@ -import type { - EnvironmentId, - VcsListRefsInput, - VcsListRefsResult, - VcsRef as ContractVcsRef, -} from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry, type AsyncResult } from "effect/unstable/reactivity"; - -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface VcsRefTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly query?: string | null; -} - -export interface VcsRefScope { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export interface VcsRefState { - readonly data: VcsListRefsResult | null; - readonly isPending: boolean; - readonly error: string | null; -} - -export type VcsRef = ContractVcsRef; -export type VcsRefClient = Pick; - -export const EMPTY_VCS_REF_STATE = Object.freeze({ - data: null, - isPending: false, - error: null, -}); - -const INITIAL_VCS_REF_STATE = Object.freeze({ - data: null, - isPending: true, - error: null, -}); - -const knownVcsRefKeys = new Set(); - -export const vcsRefStateAtom = Atom.family((key: string) => { - knownVcsRefKeys.add(key); - return Atom.make(EMPTY_VCS_REF_STATE).pipe(Atom.keepAlive, Atom.withLabel(`vcs-refs:${key}`)); -}); - -export const EMPTY_VCS_REF_ATOM = Atom.make(EMPTY_VCS_REF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-refs:null"), -); - -function normalizeQuery(query: string | null | undefined): string { - return query?.trim() ?? ""; -} - -export function getVcsRefTargetKey(target: VcsRefTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - - return `${target.environmentId}:${target.cwd}:${normalizeQuery(target.query)}`; -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load refs."; -} - -function mergeRefs( - previous: ReadonlyArray, - next: ReadonlyArray, -): ReadonlyArray { - const merged = new Map(); - for (const branch of previous) { - merged.set(branch.name, branch); - } - for (const branch of next) { - merged.set(branch.name, branch); - } - return [...merged.values()]; -} - -export interface VcsRefManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly watchLimit?: number; - readonly staleTimeMs?: number; - readonly onBackgroundError?: (error: unknown) => void; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -const NOOP: () => void = () => undefined; - -export function createVcsRefManager(config: VcsRefManagerConfig) { - const inFlight = new Map< - string, - { - readonly client: VcsRefClient; - readonly promise: Promise; - } - >(); - const loadVersions = new Map(); - const watched = new Map(); - const lastLoadedAt = new Map(); - const refreshTargets = new Map(); - const watchLoadOptions = - config.watchLimit === undefined - ? undefined - : { limit: config.watchLimit, preserveLoadedRefs: true }; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? load(target, undefined, watchLoadOptions) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: config.staleTimeMs ?? 0, - revalidateOnMount: true, - }), - Atom.withLabel(`vcs-refs:watched-refresh:${targetKey}`), - ), - ); - - function getLoadVersion(targetKey: string): number { - return loadVersions.get(targetKey) ?? 0; - } - - function bumpLoadVersion(targetKey: string): number { - const next = getLoadVersion(targetKey) + 1; - loadVersions.set(targetKey, next); - return next; - } - - function getSnapshot(target: VcsRefTarget): VcsRefState { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null) { - return EMPTY_VCS_REF_STATE; - } - return config.getRegistry().get(vcsRefStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: VcsRefState): void { - config.getRegistry().set(vcsRefStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); - setState( - targetKey, - current.data === null ? INITIAL_VCS_REF_STATE : { ...current, isPending: true, error: null }, - ); - } - - function setData(targetKey: string, data: VcsListRefsResult): void { - lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); - setState(targetKey, { - data, - isPending: false, - error: null, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - isPending: false, - error: toErrorMessage(error), - }); - } - - async function load( - target: VcsRefTarget, - client?: VcsRefClient, - options?: { - readonly cursor?: number; - readonly limit?: number; - readonly append?: boolean; - readonly preserveLoadedRefs?: boolean; - }, - ): Promise { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return null; - } - refreshTargets.set(targetKey, target); - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - return getSnapshot(target).data; - } - - const inFlightKey = `${targetKey}:${options?.cursor ?? "start"}:${options?.append ? "append" : "replace"}`; - const existing = inFlight.get(inFlightKey); - if (existing && existing.client === resolved) { - return existing.promise; - } - - markPending(targetKey); - const loadVersion = bumpLoadVersion(targetKey); - - const current = getSnapshot(target).data; - const request: VcsListRefsInput = { - cwd: target.cwd, - ...(normalizeQuery(target.query).length > 0 ? { query: normalizeQuery(target.query) } : {}), - ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}), - ...(options?.limit !== undefined ? { limit: options.limit } : {}), - }; - - const promise = resolved.listRefs(request).then( - (result) => { - const nextData = - options?.append && current - ? { - ...result, - refs: mergeRefs(current.refs, result.refs), - } - : options?.preserveLoadedRefs && current && current.refs.length > result.refs.length - ? { - ...result, - refs: mergeRefs(result.refs, current.refs), - nextCursor: current.nextCursor, - totalCount: Math.max(result.totalCount, current.totalCount), - } - : result; - if (getLoadVersion(targetKey) === loadVersion) { - setData(targetKey, nextData); - } - return nextData; - }, - (error) => { - if (getLoadVersion(targetKey) === loadVersion) { - setError(targetKey, error); - } - throw error; - }, - ); - - inFlight.set(inFlightKey, { client: resolved, promise }); - try { - return await promise; - } finally { - if (inFlight.get(inFlightKey)?.promise === promise) { - inFlight.delete(inFlightKey); - } - } - } - - function loadInBackground( - target: VcsRefTarget, - client: VcsRefClient, - options?: { - readonly cursor?: number; - readonly limit?: number; - readonly append?: boolean; - readonly preserveLoadedRefs?: boolean; - }, - ): void { - void load(target, client, options).catch((error: unknown) => { - config.onBackgroundError?.(error); - }); - } - - async function loadNext( - target: VcsRefTarget, - client?: VcsRefClient, - options?: { readonly limit?: number }, - ): Promise { - const current = getSnapshot(target).data; - if (!current?.nextCursor && current?.nextCursor !== 0) { - return current ?? null; - } - - return load(target, client, { - cursor: current.nextCursor, - append: true, - ...(options?.limit !== undefined ? { limit: options.limit } : {}), - }); - } - - function refreshWatchedTarget(targetKey: string, target: VcsRefTarget, client?: VcsRefClient) { - refreshTargets.set(targetKey, target); - - if (client || config.staleTimeMs === undefined) { - const resolved = - client ?? (target.environmentId ? config.getClient(target.environmentId) : null); - if (resolved) { - loadInBackground(target, resolved, watchLoadOptions); - } - return; - } - - const lastLoaded = lastLoadedAt.get(targetKey); - if ( - lastLoaded !== undefined && - Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs - ) { - return; - } - - const result = config - .getRegistry() - .get(watchedRefreshAtom(targetKey)) as AsyncResult.AsyncResult< - VcsListRefsResult | null, - unknown - >; - if (result._tag === "Failure") { - config.onBackgroundError?.(result.cause); - } - } - - function watch(target: VcsRefTarget, client?: VcsRefClient): () => void { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - refreshWatchedTarget(targetKey, target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: VcsRefClient | null = null; - const sync = () => { - const resolved = config.getClient(target.environmentId!); - if (!resolved) { - currentClient = null; - return; - } - if (currentClient === resolved) { - return; - } - - const hadClient = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, hadClient ? resolved : undefined); - }; - - const unsubscribe = config.subscribeClientChanges(sync); - sync(); - teardown = unsubscribe; - } else { - const resolved = config.getClient(target.environmentId); - if (!resolved) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function invalidate(target?: VcsRefTarget): void { - if (target) { - const targetKey = getVcsRefTargetKey(target); - if (targetKey !== null) { - bumpLoadVersion(targetKey); - setState(targetKey, EMPTY_VCS_REF_STATE); - for (const key of inFlight.keys()) { - if (key.startsWith(`${targetKey}:`)) { - inFlight.delete(key); - } - } - } - return; - } - - for (const key of knownVcsRefKeys) { - bumpLoadVersion(key); - setState(key, EMPTY_VCS_REF_STATE); - } - inFlight.clear(); - } - - function invalidateScope(scope: VcsRefScope): void { - if (scope.environmentId === null || scope.cwd === null) { - return; - } - - const keyPrefix = `${scope.environmentId}:${scope.cwd}:`; - for (const key of knownVcsRefKeys) { - if (key.startsWith(keyPrefix)) { - bumpLoadVersion(key); - setState(key, EMPTY_VCS_REF_STATE); - } - } - - for (const key of inFlight.keys()) { - if (key.startsWith(keyPrefix)) { - inFlight.delete(key); - } - } - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - inFlight.clear(); - loadVersions.clear(); - lastLoadedAt.clear(); - refreshTargets.clear(); - invalidate(); - } - - return { - getSnapshot, - watch, - load, - loadNext, - invalidate, - invalidateScope, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsStatusState.test.ts b/packages/client-runtime/src/vcsStatusState.test.ts deleted file mode 100644 index c671cb49742..00000000000 --- a/packages/client-runtime/src/vcsStatusState.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type VcsStatusClient, - createVcsStatusManager, - getVcsStatusDataForTarget, -} from "./vcsStatusState.ts"; - -/* ─── Test helpers ──────────────────────────────────────────────────── */ - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const BASE_STATUS: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/push-status", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -function createMockClient(): { - client: VcsStatusClient; - listeners: Set<(event: VcsStatusResult) => void>; - emit: (event: VcsStatusResult) => void; -} { - const listeners = new Set<(event: VcsStatusResult) => void>(); - const client: VcsStatusClient = { - refreshStatus: vi.fn(async (input: { cwd: string }) => ({ - ...BASE_STATUS, - refName: `${input.cwd}-refreshed`, - })), - onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => - registerListener(listeners, listener), - ), - }; - return { - client, - listeners, - emit: (event) => { - for (const listener of listeners) listener(event); - }, - }; -} - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; -const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const; -const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const; -const TARGET_KEY = "env-local:/repo"; -const PENDING = { - targetKey: TARGET_KEY, - data: null, - error: null, - cause: null, - isPending: true, -}; -const EMPTY = { - targetKey: null, - data: null, - error: null, - cause: null, - isPending: false, -}; - -/* ─── Tests ─────────────────────────────────────────────────────────── */ - -describe("createVcsStatusManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - describe("with explicit client (no reconnection)", () => { - it("starts in a pending state when watching", () => { - const { client } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - manager.watch(TARGET, client); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - manager.reset(); - }); - - it("shares one subscription per cwd and updates the snapshot", () => { - const { client, listeners, emit } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const releaseA = manager.watch(TARGET, client); - const releaseB = manager.watch(TARGET, client); - - expect(client.onStatus).toHaveBeenCalledOnce(); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - - emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual({ - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - releaseA(); - expect(listeners.size).toBe(1); - - releaseB(); - expect(listeners.size).toBe(0); - }); - - it("refreshes via unary RPC without restarting the stream", async () => { - const { client, emit } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - emit(BASE_STATUS); - - const refreshed = await manager.refresh(TARGET, client); - - expect(client.onStatus).toHaveBeenCalledOnce(); - expect(client.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); - expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); - - // Snapshot still reflects stream data, not the refresh response - expect(manager.getSnapshot(TARGET)).toEqual({ - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - release(); - }); - - it("keeps subscriptions isolated by environment when cwds match", () => { - const local = createMockClient(); - const remote = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const releaseLocal = manager.watch(TARGET, local.client); - const releaseRemote = manager.watch(OTHER_ENV_TARGET, remote.client); - - local.emit(BASE_STATUS); - remote.emit({ ...BASE_STATUS, refName: "remote-branch" }); - - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - expect(manager.getSnapshot(OTHER_ENV_TARGET).data?.refName).toBe("remote-branch"); - - releaseLocal(); - releaseRemote(); - }); - - it("rejects status data from a previous cwd during target transitions", () => { - const staleState = { - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }; - - expect(getVcsStatusDataForTarget(staleState, FRESH_TARGET)).toBeNull(); - expect(getVcsStatusDataForTarget(staleState, TARGET)).toBe(BASE_STATUS); - }); - - it("returns null from refresh when no client is available", async () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - await expect(manager.refresh(TARGET)).resolves.toBeNull(); - }); - - it("returns empty state for null targets", () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - expect(manager.getSnapshot({ environmentId: null, cwd: null })).toEqual(EMPTY); - }); - }); - - describe("with subscribeClientChanges (reconnection)", () => { - it("waits for a delayed client registration", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => clients.get(envId)?.client ?? null, - getClientIdentity: (envId) => (clients.has(envId) ? envId : null), - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - - // Register the client - const mock = createMockClient(); - clients.set("env-local", mock); - for (const listener of connectionListeners) listener(); - - mock.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual({ - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - release(); - }); - - it("resubscribes after client is removed and re-registered", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => clients.get(envId)?.client ?? null, - getClientIdentity: (envId) => - clients.get(envId) ? `identity:${envId}:${clients.size}` : null, - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - // Register first client and watch - const first = createMockClient(); - clients.set("env-local", first); - const release = manager.watch(TARGET); - - first.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - - // Remove client - clients.delete("env-local"); - for (const listener of connectionListeners) listener(); - - expect(manager.getSnapshot(TARGET)).toEqual({ - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: true, - }); - - // Register new client (different identity) - const second = createMockClient(); - clients.set("env-local", second); - for (const listener of connectionListeners) listener(); - - second.emit({ ...BASE_STATUS, refName: "reconnected-branch" }); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("reconnected-branch"); - - release(); - }); - - it("cleans up connection listener on unwatch", () => { - const connectionListeners = new Set<() => void>(); - const mock = createMockClient(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - getClientIdentity: () => "id", - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(connectionListeners.size).toBe(1); - - release(); - expect(connectionListeners.size).toBe(0); - expect(mock.listeners.size).toBe(0); - }); - }); - - describe("with getClient config (one-shot)", () => { - it("resolves client from config and subscribes", () => { - const mock = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => (envId === "env-local" ? mock.client : null), - }); - - const release = manager.watch(TARGET); - expect(mock.client.onStatus).toHaveBeenCalledOnce(); - - mock.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - - release(); - expect(mock.listeners.size).toBe(0); - }); - - it("returns noop when client is not available", () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - release(); // should not throw - }); - }); - - describe("reset", () => { - it("tears down all active subscriptions", () => { - const mock = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - manager.watch(TARGET); - manager.watch(FRESH_TARGET); - expect(mock.listeners.size).toBe(2); - - manager.reset(); - expect(mock.listeners.size).toBe(0); - }); - }); -}); diff --git a/packages/client-runtime/src/vcsStatusState.ts b/packages/client-runtime/src/vcsStatusState.ts deleted file mode 100644 index 08c8f6227e7..00000000000 --- a/packages/client-runtime/src/vcsStatusState.ts +++ /dev/null @@ -1,306 +0,0 @@ -import type { EnvironmentId, GitManagerServiceError, VcsStatusResult } from "@t3tools/contracts"; -import type * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -/* ─── Types ─────────────────────────────────────────────────────────── */ - -export interface VcsStatusState { - readonly targetKey: string | null; - readonly data: VcsStatusResult | null; - readonly error: GitManagerServiceError | null; - readonly cause: Cause.Cause | null; - readonly isPending: boolean; -} - -export interface VcsStatusTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export type VcsStatusClient = Pick; - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -/* ─── Constants ─────────────────────────────────────────────────────── */ - -const NOOP: () => void = () => undefined; - -export const EMPTY_VCS_STATUS_STATE = Object.freeze({ - targetKey: null, - data: null, - error: null, - cause: null, - isPending: false, -}); - -function initialVcsStatusState(targetKey: string): VcsStatusState { - return { - targetKey, - data: null, - error: null, - cause: null, - isPending: true, - }; -} - -/* ─── Atoms ─────────────────────────────────────────────────────────── */ - -const knownVcsStatusKeys = new Set(); - -export const vcsStatusStateAtom = Atom.family((key: string) => { - knownVcsStatusKeys.add(key); - return Atom.make(initialVcsStatusState(key)).pipe( - Atom.keepAlive, - Atom.withLabel(`vcs-status:${key}`), - ); -}); - -export const EMPTY_VCS_STATUS_ATOM = Atom.make(EMPTY_VCS_STATUS_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-status:null"), -); - -/* ─── Helpers ───────────────────────────────────────────────────────── */ - -export function getVcsStatusTargetKey(target: VcsStatusTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - return `${target.environmentId}:${target.cwd}`; -} - -export function getVcsStatusDataForTarget( - state: VcsStatusState, - target: VcsStatusTarget, -): VcsStatusResult | null { - const targetKey = getVcsStatusTargetKey(target); - return targetKey !== null && state.targetKey === targetKey ? state.data : null; -} - -/* ─── Subscription manager ──────────────────────────────────────────── */ - -export interface VcsStatusManagerConfig { - /** - * Get the atom registry to read/write VCS status atoms. - */ - readonly getRegistry: () => AtomRegistry.AtomRegistry; - /** Resolve a VCS client for an environment. */ - readonly getClient: (environmentId: EnvironmentId) => VcsStatusClient | null; - /** - * Optional: get a stable identity for the current client. - * Used to detect reconnections — when the identity changes the - * manager tears down the old `onStatus` stream and subscribes anew. - */ - readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; - /** - * Optional: subscribe to environment-connection changes. - * When provided the manager reacts to client appear / disappear / - * reconnect events instead of doing a one-shot resolution. - */ - readonly subscribeClientChanges?: (listener: () => void) => () => void; -} - -const VCS_STATUS_REFRESH_DEBOUNCE_MS = 1_000; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -export function createVcsStatusManager(config: VcsStatusManagerConfig) { - const watched = new Map(); - const refreshInFlight = new Map>(); - const lastRefreshAt = new Map(); - - /* ── Atom helpers ───────────────────────────────────────────────── */ - - function markPending(targetKey: string): void { - const atom = vcsStatusStateAtom(targetKey); - const current = config.getRegistry().get(atom); - const next: VcsStatusState = - current.data === null - ? initialVcsStatusState(targetKey) - : { ...current, error: null, cause: null, isPending: true }; - if ( - current.data === next.data && - current.error === next.error && - current.cause === next.cause && - current.isPending === next.isPending - ) { - return; - } - config.getRegistry().set(atom, next); - } - - function setData(targetKey: string, status: VcsStatusResult): void { - config.getRegistry().set(vcsStatusStateAtom(targetKey), { - targetKey, - data: status, - error: null, - cause: null, - isPending: false, - }); - } - - /* ── Core subscription ──────────────────────────────────────────── */ - - function subscribeStream(targetKey: string, cwd: string, client: VcsStatusClient): () => void { - markPending(targetKey); - return client.onStatus({ cwd }, (status) => setData(targetKey, status), { - onResubscribe: () => markPending(targetKey), - }); - } - - /* ── Dynamic subscription (handles reconnection) ────────────────── */ - - function createDynamicSubscription(targetKey: string, target: VcsStatusTarget): () => void { - const environmentId = target.environmentId!; - const cwd = target.cwd!; - let currentIdentity: string | null = null; - let currentUnsub = NOOP; - - const sync = () => { - const client = config.getClient(environmentId); - const identity = client ? (config.getClientIdentity?.(environmentId) ?? environmentId) : null; - - if (!client || identity === null) { - if (currentIdentity !== null) { - currentUnsub(); - currentUnsub = NOOP; - currentIdentity = null; - } - markPending(targetKey); - return; - } - - if (currentIdentity === identity) return; - - currentUnsub(); - currentIdentity = identity; - currentUnsub = subscribeStream(targetKey, cwd, client); - }; - - const unsubChanges = config.subscribeClientChanges!(sync); - sync(); - - return () => { - unsubChanges(); - currentUnsub(); - }; - } - - /* ── Public API ─────────────────────────────────────────────────── */ - - /** - * Begin watching VCS status for `target`. - * - * Multiple watchers sharing the same `environmentId:cwd` key share - * one `onStatus` WS subscription (ref-counted). - * - * @param target The environment + cwd to watch. - * @param client Optional pre-resolved client — skips `getClient` - * lookup and reconnection handling. Useful in tests. - * @returns An unwatch function. - */ - function watch(target: VcsStatusTarget, client?: VcsStatusClient): () => void { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - // Explicit client — direct subscription, no reconnection handling. - teardown = subscribeStream(targetKey, target.cwd, client); - } else if (config.subscribeClientChanges) { - // Dynamic client — subscribe to connection changes for reconnection. - teardown = createDynamicSubscription(targetKey, target); - } else { - // One-shot client resolution. - const resolved = config.getClient(target.environmentId); - if (!resolved) return NOOP; - teardown = subscribeStream(targetKey, target.cwd, resolved); - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) return; - - entry.refCount -= 1; - if (entry.refCount > 0) return; - - entry.teardown(); - watched.delete(targetKey); - } - - /** - * Trigger a one-shot `refreshStatus` RPC for a target. - * Debounced (1 s) and deduplicated (in-flight). - * The server-side refresh pushes a new event on the existing - * `onStatus` stream, so the subscription picks it up automatically. - */ - function refresh( - target: VcsStatusTarget, - client?: VcsStatusClient, - ): Promise { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null || target.cwd === null) { - return Promise.resolve(null); - } - - const resolved = - client ?? (target.environmentId ? config.getClient(target.environmentId) : null); - if (!resolved) { - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) return existing; - - const requestedAt = nowMs(); - const last = lastRefreshAt.get(targetKey) ?? 0; - if (requestedAt - last < VCS_STATUS_REFRESH_DEBOUNCE_MS) { - return Promise.resolve(getSnapshot(target).data); - } - - lastRefreshAt.set(targetKey, requestedAt); - const promise = resolved - .refreshStatus({ cwd: target.cwd }) - .finally(() => refreshInFlight.delete(targetKey)); - refreshInFlight.set(targetKey, promise); - return promise; - } - - function getSnapshot(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null) return EMPTY_VCS_STATUS_STATE; - return config.getRegistry().get(vcsStatusStateAtom(targetKey)); - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - refreshInFlight.clear(); - lastRefreshAt.clear(); - for (const key of knownVcsStatusKeys) { - config.getRegistry().set(vcsStatusStateAtom(key), initialVcsStatusState(key)); - } - knownVcsStatusKeys.clear(); - } - - return { watch, refresh, getSnapshot, reset }; -} diff --git a/packages/client-runtime/src/wsRpcClient.test.ts b/packages/client-runtime/src/wsRpcClient.test.ts deleted file mode 100644 index 584fb958fba..00000000000 --- a/packages/client-runtime/src/wsRpcClient.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { - VcsStatusLocalResult, - VcsStatusRemoteResult, - VcsStatusStreamEvent, -} from "@t3tools/contracts"; -import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -vi.mock("./wsTransport.ts", () => ({ - WsTransport: class WsTransport { - dispose = vi.fn(async () => undefined); - reconnect = vi.fn(async () => undefined); - request = vi.fn(); - requestStream = vi.fn(); - subscribe = vi.fn(() => () => undefined); - }, -})); - -import { createWsRpcClient } from "./wsRpcClient.ts"; -import type { WsTransport } from "./wsTransport.ts"; - -const baseLocalStatus: VcsStatusLocalResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/demo", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, -}; - -const baseRemoteStatus: VcsStatusRemoteResult = { - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -describe("createWsRpcClient", () => { - it("runs beforeReconnect before awaiting transport.reconnect", async () => { - const order: string[] = []; - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => { - order.push("reconnect"); - }), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe: vi.fn(() => () => undefined), - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport, { - beforeReconnect: () => { - order.push("beforeReconnect"); - }, - }); - - await client.reconnect(); - expect(order).toEqual(["beforeReconnect", "reconnect"]); - }); - - it("delegates heartbeat freshness to the transport", () => { - const isHeartbeatFresh = vi.fn(() => true); - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh, - request: vi.fn(), - requestStream: vi.fn(), - subscribe: vi.fn(() => () => undefined), - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - - expect(client.isHeartbeatFresh()).toBe(true); - expect(isHeartbeatFresh).toHaveBeenCalledOnce(); - }); - - it("reduces vcs status stream events into flat status snapshots", () => { - const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { - for (const event of [ - { - _tag: "snapshot", - local: baseLocalStatus, - remote: null, - }, - { - _tag: "remoteUpdated", - remote: baseRemoteStatus, - }, - { - _tag: "localUpdated", - local: { - ...baseLocalStatus, - hasWorkingTreeChanges: true, - }, - }, - ] satisfies VcsStatusStreamEvent[]) { - listener(event as TValue); - } - return () => undefined; - }); - - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe, - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - const listener = vi.fn(); - - client.vcs.onStatus({ cwd: "/repo" }, listener); - - expect(listener.mock.calls).toEqual([ - [ - { - ...baseLocalStatus, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - pr: null, - }, - ], - [ - { - ...baseLocalStatus, - ...baseRemoteStatus, - }, - ], - [ - { - ...baseLocalStatus, - ...baseRemoteStatus, - hasWorkingTreeChanges: true, - }, - ], - ]); - }); - - it("tags stream subscriptions for targeted resubscribe handling", () => { - const subscribe = vi.fn(() => () => undefined); - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe, - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - const listener = vi.fn(); - - client.terminal.onMetadata(listener); - client.vcs.onStatus({ cwd: "/repo" }, listener); - client.server.subscribeConfig(listener); - client.orchestration.subscribeThread({ threadId: ThreadId.make("thread-1") }, listener); - - const subscribeCalls = subscribe.mock.calls as unknown as Array< - readonly [unknown, unknown, { readonly tag?: string }?] - >; - expect(subscribeCalls.map((call) => call[2]?.tag)).toEqual([ - WS_METHODS.subscribeTerminalMetadata, - WS_METHODS.subscribeVcsStatus, - WS_METHODS.subscribeServerConfig, - ORCHESTRATION_WS_METHODS.subscribeThread, - ]); - }); -}); diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts deleted file mode 100644 index 18a6559f315..00000000000 --- a/packages/client-runtime/src/wsRpcClient.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { - type GitActionProgressEvent, - type GitRunStackedActionInput, - type GitRunStackedActionResult, - type LocalApi, - ORCHESTRATION_WS_METHODS, - type RelayClientInstallProgressEvent, - type RelayClientStatus, - type ServerSettingsPatch, - type VcsStatusResult, - type VcsStatusStreamEvent, - WS_METHODS, -} from "@t3tools/contracts"; -import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; -import type * as Effect from "effect/Effect"; -import type * as Stream from "effect/Stream"; - -import { type WsRpcProtocolClient } from "./wsRpcProtocol.ts"; -import { WsTransport } from "./wsTransport.ts"; - -type RpcTag = keyof WsRpcProtocolClient & string; -type RpcMethod = WsRpcProtocolClient[TTag]; -type RpcInput = Parameters>[0]; - -interface StreamSubscriptionOptions { - readonly onResubscribe?: () => void; -} - -function subscriptionOptions( - options: StreamSubscriptionOptions | undefined, - tag: string, -): StreamSubscriptionOptions & { readonly tag: string } { - return { - ...options, - tag, - }; -} - -type RpcUnaryMethod = - RpcMethod extends (input: any, options?: any) => Effect.Effect - ? (input: RpcInput) => Promise - : never; - -type RpcUnaryNoArgMethod = - RpcMethod extends (input: any, options?: any) => Effect.Effect - ? () => Promise - : never; - -type RpcStreamMethod = - RpcMethod extends (input: any, options?: any) => Stream.Stream - ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void - : never; - -type RpcInputStreamMethod = - RpcMethod extends (input: any, options?: any) => Stream.Stream - ? ( - input: RpcInput, - listener: (event: TEvent) => void, - options?: StreamSubscriptionOptions, - ) => () => void - : never; - -interface GitRunStackedActionOptions { - readonly onProgress?: (event: GitActionProgressEvent) => void; -} - -export interface WsRpcClient { - readonly dispose: () => Promise; - readonly reconnect: () => Promise; - readonly isHeartbeatFresh: () => boolean; - readonly terminal: { - readonly open: RpcUnaryMethod; - readonly attach: RpcInputStreamMethod; - readonly write: RpcUnaryMethod; - readonly resize: RpcUnaryMethod; - readonly clear: RpcUnaryMethod; - readonly restart: RpcUnaryMethod; - readonly close: RpcUnaryMethod; - readonly onEvent: RpcStreamMethod; - readonly onMetadata: RpcStreamMethod; - }; - readonly preview: { - readonly open: RpcUnaryMethod; - readonly navigate: RpcUnaryMethod; - readonly refresh: RpcUnaryMethod; - readonly close: RpcUnaryMethod; - readonly list: RpcUnaryMethod; - readonly reportStatus: RpcUnaryMethod; - readonly automation: { - readonly connect: RpcInputStreamMethod; - readonly respond: RpcUnaryMethod; - readonly reportOwner: RpcUnaryMethod; - readonly clearOwner: RpcUnaryMethod; - }; - readonly onEvent: RpcStreamMethod; - readonly subscribePorts: RpcStreamMethod; - }; - readonly projects: { - readonly listEntries: RpcUnaryMethod; - readonly readFile: RpcUnaryMethod; - readonly searchEntries: RpcUnaryMethod; - readonly writeFile: RpcUnaryMethod; - }; - readonly filesystem: { - readonly browse: RpcUnaryMethod; - }; - readonly assets: { - readonly createUrl: RpcUnaryMethod; - }; - readonly sourceControl: { - readonly lookupRepository: RpcUnaryMethod; - readonly cloneRepository: RpcUnaryMethod; - readonly publishRepository: RpcUnaryMethod; - }; - readonly shell: { - readonly openInEditor: (input: { - readonly cwd: Parameters[0]; - readonly editor: Parameters[1]; - }) => ReturnType; - }; - readonly vcs: { - readonly pull: RpcUnaryMethod; - readonly refreshStatus: RpcUnaryMethod; - readonly onStatus: ( - input: RpcInput, - listener: (status: VcsStatusResult) => void, - options?: StreamSubscriptionOptions, - ) => () => void; - readonly listRefs: RpcUnaryMethod; - readonly createWorktree: RpcUnaryMethod; - readonly removeWorktree: RpcUnaryMethod; - readonly createRef: RpcUnaryMethod; - readonly switchRef: RpcUnaryMethod; - readonly init: RpcUnaryMethod; - }; - readonly git: { - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Promise; - readonly resolvePullRequest: RpcUnaryMethod; - readonly preparePullRequestThread: RpcUnaryMethod< - typeof WS_METHODS.gitPreparePullRequestThread - >; - }; - readonly review: { - readonly getDiffPreview: RpcUnaryMethod; - }; - readonly server: { - readonly getConfig: RpcUnaryNoArgMethod; - readonly refreshProviders: ( - input?: RpcInput, - ) => ReturnType>; - readonly discoverSourceControl: RpcUnaryNoArgMethod< - typeof WS_METHODS.serverDiscoverSourceControl - >; - readonly updateProvider: RpcUnaryMethod; - readonly upsertKeybinding: RpcUnaryMethod; - readonly removeKeybinding: RpcUnaryMethod; - readonly getSettings: RpcUnaryNoArgMethod; - readonly updateSettings: ( - patch: ServerSettingsPatch, - ) => ReturnType>; - readonly subscribeConfig: RpcStreamMethod; - readonly subscribeLifecycle: RpcStreamMethod; - readonly subscribeAuthAccess: RpcStreamMethod; - readonly getTraceDiagnostics: RpcUnaryNoArgMethod; - readonly getProcessDiagnostics: RpcUnaryNoArgMethod< - typeof WS_METHODS.serverGetProcessDiagnostics - >; - readonly getProcessResourceHistory: RpcUnaryMethod< - typeof WS_METHODS.serverGetProcessResourceHistory - >; - readonly signalProcess: RpcUnaryMethod; - }; - readonly cloud: { - readonly getRelayClientStatus: RpcUnaryNoArgMethod; - readonly installRelayClient: ( - onProgress?: (event: RelayClientInstallProgressEvent) => void, - ) => Promise; - }; - readonly orchestration: { - readonly dispatchCommand: RpcUnaryMethod; - readonly getTurnDiff: RpcUnaryMethod; - readonly getFullThreadDiff: RpcUnaryMethod; - readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod< - typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot - >; - readonly subscribeShell: RpcStreamMethod; - readonly subscribeThread: RpcInputStreamMethod; - }; -} - -export interface CreateWsRpcClientOptions { - /** Runs immediately before `transport.reconnect()` (e.g. reset reconnect UI/backoff state). */ - readonly beforeReconnect?: () => void; -} - -export function createWsRpcClient( - transport: WsTransport, - options?: CreateWsRpcClientOptions, -): WsRpcClient { - return { - dispose: () => transport.dispose(), - isHeartbeatFresh: () => transport.isHeartbeatFresh(), - reconnect: async () => { - options?.beforeReconnect?.(); - await transport.reconnect(); - }, - terminal: { - open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), - attach: (input, listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.terminalAttach](input), - listener, - subscriptionOptions(options, WS_METHODS.terminalAttach), - ), - write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), - resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), - clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), - restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), - close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), - onEvent: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeTerminalEvents]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeTerminalEvents), - ), - onMetadata: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeTerminalMetadata]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), - ), - }, - preview: { - open: (input) => transport.request((client) => client[WS_METHODS.previewOpen](input)), - navigate: (input) => transport.request((client) => client[WS_METHODS.previewNavigate](input)), - refresh: (input) => transport.request((client) => client[WS_METHODS.previewRefresh](input)), - close: (input) => transport.request((client) => client[WS_METHODS.previewClose](input)), - list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), - reportStatus: (input) => - transport.request((client) => client[WS_METHODS.previewReportStatus](input)), - automation: { - connect: (input, listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.previewAutomationConnect](input), - listener, - subscriptionOptions(options, WS_METHODS.previewAutomationConnect), - ), - respond: (input) => - transport.request((client) => client[WS_METHODS.previewAutomationRespond](input)), - reportOwner: (input) => - transport.request((client) => client[WS_METHODS.previewAutomationReportOwner](input)), - clearOwner: (input) => - transport.request((client) => client[WS_METHODS.previewAutomationClearOwner](input)), - }, - onEvent: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribePreviewEvents]({}), - listener, - options, - ), - subscribePorts: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeDiscoveredLocalServers]({}), - listener, - options, - ), - }, - projects: { - listEntries: (input) => - transport.request((client) => client[WS_METHODS.projectsListEntries](input)), - readFile: (input) => - transport.request((client) => client[WS_METHODS.projectsReadFile](input)), - searchEntries: (input) => - transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), - writeFile: (input) => - transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), - }, - filesystem: { - browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), - }, - assets: { - createUrl: (input) => - transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)), - }, - sourceControl: { - lookupRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), - cloneRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)), - publishRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)), - }, - shell: { - openInEditor: (input) => - transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), - }, - vcs: { - pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), - refreshStatus: (input) => - transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), - onStatus: (input, listener, options) => { - let current: VcsStatusResult | null = null; - return transport.subscribe( - (client) => client[WS_METHODS.subscribeVcsStatus](input), - (event: VcsStatusStreamEvent) => { - current = applyGitStatusStreamEvent(current, event); - listener(current); - }, - subscriptionOptions(options, WS_METHODS.subscribeVcsStatus), - ); - }, - listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), - createWorktree: (input) => - transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), - removeWorktree: (input) => - transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), - createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), - switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), - init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), - }, - git: { - runStackedAction: async (input, options) => { - let result: GitRunStackedActionResult | null = null; - - await transport.requestStream( - (client) => client[WS_METHODS.gitRunStackedAction](input), - (event) => { - options?.onProgress?.(event); - if (event.kind === "action_finished") { - result = event.result; - } - }, - ); - - if (result) { - return result; - } - - throw new Error("Git action stream completed without a final result."); - }, - resolvePullRequest: (input) => - transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), - preparePullRequestThread: (input) => - transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), - }, - review: { - getDiffPreview: (input) => - transport.request((client) => client[WS_METHODS.reviewGetDiffPreview](input)), - }, - server: { - getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), - refreshProviders: (input) => - transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), - discoverSourceControl: () => - transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), - updateProvider: (input) => - transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), - upsertKeybinding: (input) => - transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), - removeKeybinding: (input) => - transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)), - getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), - updateSettings: (patch) => - transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), - subscribeConfig: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeServerConfig]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeServerConfig), - ), - subscribeLifecycle: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeServerLifecycle), - ), - subscribeAuthAccess: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeAuthAccess]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeAuthAccess), - ), - getTraceDiagnostics: () => - transport.request((client) => client[WS_METHODS.serverGetTraceDiagnostics]({})), - getProcessDiagnostics: () => - transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})), - getProcessResourceHistory: (input) => - transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)), - signalProcess: (input) => - transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), - }, - cloud: { - getRelayClientStatus: () => - transport.request((client) => client[WS_METHODS.cloudGetRelayClientStatus]({})), - installRelayClient: async (onProgress) => { - let installed: RelayClientStatus | null = null; - await transport.requestStream( - (client) => client[WS_METHODS.cloudInstallRelayClient]({}), - (event) => { - onProgress?.(event); - if (event.type === "complete") { - installed = event.status; - } - }, - ); - if (installed) { - return installed; - } - throw new Error("Relay client install stream completed without a final status."); - }, - }, - orchestration: { - dispatchCommand: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), - getTurnDiff: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), - getFullThreadDiff: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), - getArchivedShellSnapshot: () => - transport.request((client) => - client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}), - ), - subscribeShell: (listener, options) => - transport.subscribe( - (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), - listener, - subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeShell), - ), - subscribeThread: (input, listener, options) => - transport.subscribe( - (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), - listener, - subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread), - ), - }, - }; -} diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts deleted file mode 100644 index 0c7174be627..00000000000 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { WsRpcGroup } from "@t3tools/contracts"; -import { normalizeBasePath } from "@t3tools/shared/basePath"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schedule from "effect/Schedule"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; -import * as Socket from "effect/unstable/socket/Socket"; - -import { - DEFAULT_RECONNECT_BACKOFF, - getReconnectDelayMs, - type ReconnectBackoffConfig, -} from "./reconnectBackoff.ts"; - -const WS_RPC_PATH = "/ws"; - -export interface WsProtocolLifecycleHandlers { - readonly getConnectionLabel?: () => string | null; - readonly getVersionMismatchHint?: () => string | null; - readonly isCloseIntentional?: () => boolean; - readonly isActive?: () => boolean; - readonly onAttempt?: (socketUrl: string) => void; - readonly onOpen?: () => void; - readonly onHeartbeatPing?: () => void; - readonly onHeartbeatPong?: () => void; - readonly onHeartbeatTimeout?: () => void; - readonly onRequestStart?: (info: { - readonly id: string; - readonly tag: string; - readonly stream: boolean; - }) => void; - readonly onRequestChunk?: (info: { - readonly id: string; - readonly tag: string; - readonly chunkCount: number; - }) => void; - readonly onRequestExit?: (info: { - readonly id: string; - readonly tag: string; - readonly stream: boolean; - }) => void; - readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void; - readonly onError?: (message: string) => void; - readonly onClose?: ( - details: { readonly code: number; readonly reason: string }, - context: { readonly intentional: boolean }, - ) => void; -} - -export interface WsRpcProtocolRequestTelemetry { - readonly onRequestSent?: (requestId: string, tag: string) => void; - readonly onRequestAcknowledged?: (requestId: string) => void; - readonly onClearTrackedRequests?: () => void; -} - -export interface WsRpcProtocolOptions { - /** Backoff configuration for reconnect retries. */ - readonly backoff?: ReconnectBackoffConfig; - /** - * Invoked before user {@link WsProtocolLifecycleHandlers} for each socket lifecycle event. - * Use for additive telemetry (connection state, clearing request trackers on disconnect). - */ - readonly telemetryLifecycle?: WsProtocolLifecycleHandlers; - /** Optional hooks around outbound requests and inbound RPC responses (latency tracking, etc.). */ - readonly requestTelemetry?: WsRpcProtocolRequestTelemetry; -} - -export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); -type RpcClientFactory = typeof makeWsRpcProtocolClient; -export type WsRpcProtocolClient = - RpcClientFactory extends Effect.Effect ? Client : never; -export type WsRpcProtocolSocketUrlProvider = string | (() => Promise); - -function formatSocketErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - return String(error); -} - -function resolveWsRpcSocketUrl(rawUrl: string): string { - const resolved = new URL(rawUrl); - if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") { - throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); - } - - resolved.pathname = `${Effect.runSync(normalizeBasePath(resolved.pathname))}${WS_RPC_PATH}`; - resolved.hash = ""; - return resolved.toString(); -} - -type ResolvedLifecycleHandlers = Required< - Pick< - WsProtocolLifecycleHandlers, - | "getConnectionLabel" - | "getVersionMismatchHint" - | "isCloseIntentional" - | "isActive" - | "onAttempt" - | "onOpen" - | "onHeartbeatPing" - | "onHeartbeatPong" - | "onHeartbeatTimeout" - | "onError" - | "onClose" - > ->; - -function defaultLifecycleHandlers(): ResolvedLifecycleHandlers { - return { - onAttempt: () => undefined, - onOpen: () => undefined, - onHeartbeatPing: () => undefined, - onHeartbeatPong: () => undefined, - onHeartbeatTimeout: () => undefined, - onError: () => undefined, - onClose: () => undefined, - getConnectionLabel: () => null, - getVersionMismatchHint: () => null, - isCloseIntentional: () => false, - isActive: () => true, - }; -} - -function resolveLifecycleHandlers( - handlers: WsProtocolLifecycleHandlers | undefined, - telemetryLifecycle: WsProtocolLifecycleHandlers | undefined, -): ResolvedLifecycleHandlers { - const defaults = defaultLifecycleHandlers(); - const isActive = handlers?.isActive ?? telemetryLifecycle?.isActive ?? defaults.isActive; - const isCloseIntentional = - handlers?.isCloseIntentional ?? - telemetryLifecycle?.isCloseIntentional ?? - defaults.isCloseIntentional; - - return { - getConnectionLabel: () => - handlers?.getConnectionLabel?.() ?? telemetryLifecycle?.getConnectionLabel?.() ?? null, - getVersionMismatchHint: () => - handlers?.getVersionMismatchHint?.() ?? - telemetryLifecycle?.getVersionMismatchHint?.() ?? - null, - isActive, - isCloseIntentional, - onAttempt: (socketUrl) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onAttempt?.(socketUrl); - handlers?.onAttempt?.(socketUrl); - }, - onOpen: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onOpen?.(); - handlers?.onOpen?.(); - }, - onHeartbeatPing: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatPing?.(); - handlers?.onHeartbeatPing?.(); - }, - onHeartbeatPong: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatPong?.(); - handlers?.onHeartbeatPong?.(); - }, - onHeartbeatTimeout: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatTimeout?.(); - handlers?.onHeartbeatTimeout?.(); - }, - onError: (message) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onError?.(message); - handlers?.onError?.(message); - }, - onClose: (details, context) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onClose?.(details, context); - handlers?.onClose?.(details, context); - }, - }; -} - -export function createWsRpcProtocolLayer( - url: WsRpcProtocolSocketUrlProvider, - handlers?: WsProtocolLifecycleHandlers, - options?: WsRpcProtocolOptions, -) { - const lifecycle = resolveLifecycleHandlers(handlers, options?.telemetryLifecycle); - const backoff = options?.backoff ?? DEFAULT_RECONNECT_BACKOFF; - const requestTelemetry = options?.requestTelemetry; - const resolvedUrl = - typeof url === "function" - ? Effect.promise(() => url()).pipe( - Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)), - Effect.tapError((error) => - Effect.sync(() => { - lifecycle.onError(formatSocketErrorMessage(error)); - }), - ), - Effect.orDie, - ) - : resolveWsRpcSocketUrl(url); - - const trackingWebSocketConstructorLayer = Layer.succeed( - Socket.WebSocketConstructor, - (socketUrl, protocols) => { - lifecycle.onAttempt(socketUrl); - const socket = new globalThis.WebSocket(socketUrl, protocols); - - socket.addEventListener( - "open", - () => { - lifecycle.onOpen(); - }, - { once: true }, - ); - socket.addEventListener( - "error", - () => { - lifecycle.onError("Unable to connect to the T3 server WebSocket."); - }, - { once: true }, - ); - socket.addEventListener("message", (event) => { - try { - const message = JSON.parse(String(event.data)) as { readonly _tag?: string }; - if (message._tag === "Pong") { - lifecycle.onHeartbeatPong(); - } - } catch { - // Ignore malformed messages here; the Effect RPC parser still owns protocol errors. - } - }); - socket.addEventListener( - "close", - (event) => { - lifecycle.onClose( - { - code: event.code, - reason: event.reason, - }, - { - intentional: lifecycle.isCloseIntentional(), - }, - ); - }, - { once: true }, - ); - - return socket; - }, - ); - const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( - Layer.provide(trackingWebSocketConstructorLayer), - ); - - const baseSchedule = - backoff.maxRetries === null ? Schedule.forever : Schedule.recurs(backoff.maxRetries); - const retryPolicy = Schedule.addDelay(baseSchedule, (retryCount) => - Effect.succeed(Duration.millis(getReconnectDelayMs(retryCount, backoff) ?? 0)), - ); - const protocolLayer = Layer.effect( - RpcClient.Protocol, - Effect.map( - RpcClient.makeProtocolSocket({ - retryPolicy, - retryTransientErrors: true, - }), - (protocol) => ({ - ...protocol, - run: (clientId, writeResponse) => - protocol.run(clientId, (response) => { - if (response._tag === "Chunk" || response._tag === "Exit") { - requestTelemetry?.onRequestAcknowledged?.(response.requestId); - } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") { - requestTelemetry?.onClearTrackedRequests?.(); - } - return writeResponse(response); - }), - send: (clientId, request, transferables) => { - if (request._tag === "Request") { - requestTelemetry?.onRequestSent?.(request.id, request.tag); - if (lifecycle.isActive()) { - handlers?.onRequestStart?.({ - id: request.id, - tag: request.tag, - stream: false, - }); - } - } - return protocol.send(clientId, request, transferables); - }, - }), - ), - ); - const connectionHooksLayer = Layer.succeed( - RpcClient.ConnectionHooks, - RpcClient.ConnectionHooks.of({ - onConnect: Effect.void, - onDisconnect: Effect.void, - }), - ); - - return protocolLayer.pipe( - Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), - ); -} diff --git a/packages/client-runtime/src/wsTransport.test.ts b/packages/client-runtime/src/wsTransport.test.ts deleted file mode 100644 index 72a698d2fcf..00000000000 --- a/packages/client-runtime/src/wsTransport.test.ts +++ /dev/null @@ -1,959 +0,0 @@ -import { WS_METHODS } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Stream from "effect/Stream"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { WsTransport } from "./wsTransport.ts"; - -type WsEventType = "open" | "message" | "close" | "error"; -type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; -type WsListener = (event?: WsEvent) => void; - -const sockets: MockWebSocket[] = []; - -class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readyState = MockWebSocket.CONNECTING; - readonly sent: string[] = []; - readonly url: string; - private readonly listeners = new Map>(); - - constructor(url: string) { - this.url = url; - sockets.push(this); - } - - addEventListener(type: WsEventType, listener: WsListener) { - const listeners = this.listeners.get(type) ?? new Set(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - - removeEventListener(type: WsEventType, listener: WsListener) { - this.listeners.get(type)?.delete(listener); - } - - send(data: string) { - this.sent.push(data); - } - - close(code = 1000, reason = "") { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", { code, reason, type: "close" }); - } - - open() { - this.readyState = MockWebSocket.OPEN; - this.emit("open", { type: "open" }); - } - - serverMessage(data: unknown) { - this.emit("message", { data, type: "message" }); - } - - error() { - this.emit("error", { type: "error" }); - } - - private emit(type: WsEventType, event?: WsEvent) { - const listeners = this.listeners.get(type); - if (!listeners) return; - for (const listener of listeners) { - listener(event); - } - } -} - -const originalWebSocket = globalThis.WebSocket; -const originalFetch = globalThis.fetch; -const transports: WsTransport[] = []; - -function getSocket(): MockWebSocket { - const socket = sockets.at(-1); - if (!socket) { - throw new Error("Expected a websocket instance"); - } - return socket; -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = performance.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (performance.now() - startedAt >= timeoutMs) { - throw error; - } - await Effect.runPromise(Effect.sleep(Duration.millis(10))); - } - } -} - -function createTransport(...args: ConstructorParameters): WsTransport { - const transport = new WsTransport(...args); - transports.push(transport); - return transport; -} - -beforeEach(() => { - vi.useRealTimers(); - sockets.length = 0; - transports.length = 0; - - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - location: { - origin: "http://localhost:3020", - hostname: "localhost", - port: "3020", - protocol: "http:", - }, - desktopBridge: undefined, - }, - }); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { onLine: true }, - }); - - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; -}); - -afterEach(async () => { - await Promise.allSettled(transports.map((transport) => transport.dispose())); - transports.length = 0; - globalThis.WebSocket = originalWebSocket; - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); -}); - -describe("WsTransport", () => { - it("normalizes root websocket urls to /ws and preserves query params", async () => { - const transport = createTransport("ws://localhost:3020/?token=secret-token"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); - await transport.dispose(); - }); - - it("uses an explicit secure websocket base url", async () => { - const transport = createTransport("wss://app.example.com"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("wss://app.example.com/ws"); - await transport.dispose(); - }); - - it("uses an explicit insecure websocket base url for remote backends", async () => { - const transport = createTransport("ws://192.168.1.44:3773"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws"); - await transport.dispose(); - }); - - it("supports async websocket url providers", async () => { - const transport = createTransport(async () => "wss://remote.example.com/?wsTicket=dynamic"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("wss://remote.example.com/ws?wsTicket=dynamic"); - await transport.dispose(); - }); - - it("invokes optional lifecycle handlers when the socket opens and closes", async () => { - const onOpen = vi.fn(); - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { - onOpen, - onClose, - }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(onOpen).toHaveBeenCalledOnce(); - }); - - socket.close(1012, "service restart"); - - await waitFor(() => { - expect(onClose).toHaveBeenCalledWith( - { - code: 1012, - reason: "service restart", - }, - { - intentional: false, - }, - ); - }); - - await transport.dispose(); - }); - - it("tracks heartbeat freshness from websocket pongs", async () => { - const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000); - const onHeartbeatPong = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(transport.isHeartbeatFresh()).toBe(false); - - const socket = getSocket(); - socket.open(); - socket.serverMessage(JSON.stringify({ _tag: "Pong" })); - - await waitFor(() => { - expect(onHeartbeatPong).toHaveBeenCalledOnce(); - }); - - expect(transport.isHeartbeatFresh()).toBe(true); - expect(transport.isHeartbeatFresh(500)).toBe(true); - - nowSpy.mockReturnValue(1_501); - expect(transport.isHeartbeatFresh(500)).toBe(false); - - await transport.dispose(); - }); - - it("clears heartbeat freshness when reconnecting", async () => { - vi.spyOn(performance, "now").mockReturnValue(1_000); - const onHeartbeatPong = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - firstSocket.serverMessage(JSON.stringify({ _tag: "Pong" })); - - await waitFor(() => { - expect(onHeartbeatPong).toHaveBeenCalledOnce(); - }); - expect(transport.isHeartbeatFresh()).toBe(true); - - await transport.reconnect(); - - expect(transport.isHeartbeatFresh()).toBe(false); - - await transport.dispose(); - }); - - it("does not report an intentional dispose as a close", async () => { - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onClose }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - await transport.dispose(); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("ignores stale socket lifecycle events after reconnect starts a new session", async () => { - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onClose }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - firstSocket.close(1006, "stale close"); - - expect(onClose).not.toHaveBeenCalled(); - - await transport.dispose(); - }); - - it("reconnects the websocket session without disposing the transport", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - expect(secondSocket).not.toBe(firstSocket); - expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - secondSocket.open(); - - await waitFor(() => { - expect(secondSocket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; - secondSocket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - - await transport.dispose(); - }); - - it("sends unary RPC requests and resolves successful exits", async () => { - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { - _tag: string; - id: string; - payload: unknown; - tag: string; - }; - expect(requestMessage).toMatchObject({ - _tag: "Request", - tag: WS_METHODS.serverUpsertKeybinding, - payload: { - command: "terminal.toggle", - key: "ctrl+k", - }, - }); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - - await transport.dispose(); - }); - - it("delivers stream chunks to subscribers", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; - expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); - - const welcomeEvent = { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/workspace", - projectName: "workspace", - }, - }; - - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: requestMessage.id, - values: [welcomeEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenCalledWith(welcomeEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("re-subscribes stream listeners after the stream exits", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: firstRequest.id, - values: [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/one", - projectName: "one", - }, - }, - ], - }), - ); - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: firstRequest.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await waitFor(() => { - const nextRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) - .find((message) => message._tag === "Request" && message.id !== firstRequest.id); - expect(nextRequest).toBeDefined(); - }); - expect(onResubscribe).toHaveBeenCalledOnce(); - - const secondRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) - .find( - (message): message is { _tag: "Request"; id: string; tag: string } => - message._tag === "Request" && message.id !== firstRequest.id, - ); - if (!secondRequest) { - throw new Error("Expected a resubscribe request"); - } - expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); - expect(secondRequest.id).not.toBe(firstRequest.id); - - const secondEvent = { - version: 1, - sequence: 2, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/two", - projectName: "two", - }, - }; - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: secondRequest.id, - values: [secondEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(secondEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("re-subscribes live stream listeners after an explicit transport reconnect", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await waitFor(() => { - expect(firstSocket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; - const firstEvent = { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/one", - projectName: "one", - }, - }; - - firstSocket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: firstRequest.id, - values: [firstEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(firstEvent); - }); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - expect(secondSocket).not.toBe(firstSocket); - expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); - - secondSocket.open(); - - await waitFor(() => { - expect(secondSocket.sent).toHaveLength(1); - }); - - const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as { - id: string; - tag: string; - }; - expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); - expect(secondRequest.id).not.toBe(firstRequest.id); - expect(onResubscribe).toHaveBeenCalledOnce(); - - const secondEvent = { - version: 1, - sequence: 2, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/two", - projectName: "two", - }, - }; - - secondSocket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: secondRequest.id, - values: [secondEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(secondEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: firstRequest.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await waitFor(() => { - const nextRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) - .find((message) => message._tag === "Request" && message.id !== firstRequest.id); - expect(nextRequest).toBeDefined(); - }); - expect(onResubscribe).not.toHaveBeenCalled(); - expect(listener).not.toHaveBeenCalled(); - - unsubscribe(); - await transport.dispose(); - }); - - it("does not retry stream subscriptions after application-level failures", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - let attempts = 0; - - const unsubscribe = transport.subscribe( - () => - Stream.suspend(() => { - attempts += 1; - return Stream.fail(new Error("Git command failed in GitCore.statusDetails")); - }), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(attempts).toBe(1); - }); - await Effect.runPromise(Effect.sleep(Duration.millis(50))); - - expect(attempts).toBe(1); - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", { - error: "Git command failed in GitCore.statusDetails", - }); - expect(warnSpy).not.toHaveBeenCalledWith( - "WebSocket RPC subscription disconnected", - expect.anything(), - ); - - unsubscribe(); - await transport.dispose(); - }); - - it("keeps retrying stream subscriptions after transport failures", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - let attempts = 0; - - const unsubscribe = transport.subscribe( - () => - Stream.suspend(() => { - attempts += 1; - return Stream.fail(new Error("Socket is not connected")); - }), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(attempts).toBeGreaterThanOrEqual(2); - }); - - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { - error: "Socket is not connected", - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - - const unsubscribeA = transport.subscribe( - () => Stream.fail(new Error("SocketCloseError: 1006")), - vi.fn(), - { retryDelay: 10 }, - ); - const unsubscribeB = transport.subscribe( - () => Stream.fail(new Error("SocketCloseError: 1006")), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { - error: "SocketCloseError: 1006", - }); - - unsubscribeA(); - unsubscribeB(); - await transport.dispose(); - }); - - it("streams finite request events without re-subscribing", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - const socket = getSocket(); - socket.open(); - - const requestPromise = transport.requestStream( - (client) => - client[WS_METHODS.gitRunStackedAction]({ - actionId: "action-1", - cwd: "/repo", - action: "commit", - }), - listener, - ); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - const progressEvent = { - actionId: "action-1", - cwd: "/repo", - action: "commit", - kind: "phase_started", - phase: "commit", - label: "Committing...", - } as const; - - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: requestMessage.id, - values: [progressEvent], - }), - ); - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await expect(requestPromise).resolves.toBeUndefined(); - expect(listener).toHaveBeenCalledWith(progressEvent); - expect( - socket.sent.filter((message) => { - const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; - return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; - }), - ).toHaveLength(1); - await transport.dispose(); - }); - - it("closes the client scope on the transport runtime before disposing the runtime", async () => { - const callOrder: string[] = []; - let resolveClose!: () => void; - const closePromise = new Promise((resolve) => { - resolveClose = resolve; - }); - - const runtime = { - runPromise: vi.fn(async () => { - callOrder.push("close:start"); - await closePromise; - callOrder.push("close:done"); - return undefined; - }), - dispose: vi.fn(async () => { - callOrder.push("runtime:dispose"); - }), - }; - const transport = { - disposed: false, - session: { - clientScope: {} as never, - runtime, - }, - closeSession: ( - WsTransport.prototype as unknown as { - closeSession: (session: { - clientScope: unknown; - runtime: { dispose: () => Promise; runPromise: () => Promise }; - }) => Promise; - } - ).closeSession, - } as unknown as WsTransport; - - void WsTransport.prototype.dispose.call(transport); - - expect(runtime.runPromise).toHaveBeenCalledTimes(1); - expect(runtime.dispose).not.toHaveBeenCalled(); - expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); - - resolveClose(); - - await waitFor(() => { - expect(runtime.dispose).toHaveBeenCalledTimes(1); - }); - - expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); - }); -}); diff --git a/packages/client-runtime/src/wsTransport.ts b/packages/client-runtime/src/wsTransport.ts deleted file mode 100644 index a68b0aba469..00000000000 --- a/packages/client-runtime/src/wsTransport.ts +++ /dev/null @@ -1,377 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as ManagedRuntime from "effect/ManagedRuntime"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { RpcClient } from "effect/unstable/rpc"; - -import { isTransportConnectionErrorMessage } from "./transportError.ts"; -import { - createWsRpcProtocolLayer, - makeWsRpcProtocolClient, - type WsProtocolLifecycleHandlers, - type WsRpcProtocolClient, - type WsRpcProtocolSocketUrlProvider, -} from "./wsRpcProtocol.ts"; - -export interface WsTransportOptions { - /** - * Merged into the transport `ManagedRuntime` alongside the RPC protocol layer - * (for example a `Tracer` layer for OTLP). - */ - readonly tracingLayer?: Layer.Layer; - /** - * Override protocol construction (defaults to {@link createWsRpcProtocolLayer}). - * The web app supplies its instrumented layer factory. - */ - readonly createProtocolLayer?: ( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - ) => Layer.Layer; - readonly logWarning?: (message: string, metadata: { readonly error: string }) => void; - /** - * Invoked at the start of {@link WsTransport.reconnect} before the session is replaced. - */ - readonly onBeforeReconnect?: () => void; -} - -interface SubscribeOptions { - readonly retryDelay?: Duration.Input; - readonly onResubscribe?: () => void; - readonly tag?: string; -} - -const DEFAULT_SUBSCRIPTION_RETRY_DELAY = Duration.millis(250); -const NOOP: () => void = () => undefined; - -interface TransportSession { - readonly clientPromise: Promise; - readonly clientScope: Scope.Closeable; - readonly runtime: ManagedRuntime.ManagedRuntime; -} - -function formatErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - return String(error); -} - -export class WsTransport { - private readonly url: WsRpcProtocolSocketUrlProvider; - private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; - private readonly options: WsTransportOptions | undefined; - private disposed = false; - private hasReportedTransportDisconnect = false; - private intentionalCloseDepth = 0; - private nextSessionId = 0; - private activeSessionId = 0; - private lastHeartbeatPongAt: number | null = null; - private readonly streamRequestStartListeners = new Set< - (info: { readonly tag: string }) => void - >(); - private reconnectChain: Promise = Promise.resolve(); - private session: TransportSession; - - constructor( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - options?: WsTransportOptions, - ) { - this.url = url; - this.lifecycleHandlers = lifecycleHandlers; - this.options = options; - this.session = this.createSession(); - } - - async request( - execute: (client: WsRpcProtocolClient) => Effect.Effect, - ): Promise { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const session = this.session; - const client = await session.clientPromise; - return await session.runtime.runPromise(Effect.suspend(() => execute(client))); - } - - async requestStream( - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - ): Promise { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const session = this.session; - const client = await session.clientPromise; - await session.runtime.runPromise( - Stream.runForEach(connect(client), (value) => - Effect.sync(() => { - try { - listener(value); - } catch { - // Ignore listener errors so the stream can finish cleanly. - } - }), - ), - ); - } - - subscribe( - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - options?: SubscribeOptions, - ): () => void { - if (this.disposed) { - return NOOP; - } - - let active = true; - let hasReceivedValue = false; - const retryDelayMs = Duration.toMillis( - Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY), - ); - let cancelCurrentStream: () => void = NOOP; - const onStreamRequestStart = (info: { readonly tag: string }) => { - if ( - !hasReceivedValue || - !active || - (options?.tag !== undefined && info.tag !== options.tag) - ) { - return; - } - - try { - options?.onResubscribe?.(); - } catch { - // Ignore reconnect hook failures so the stream can recover. - } - }; - this.streamRequestStartListeners.add(onStreamRequestStart); - - void (async () => { - for (;;) { - if (!active || this.disposed) { - return; - } - - const session = this.session; - try { - if (hasReceivedValue) { - try { - options?.onResubscribe?.(); - } catch { - // Ignore reconnect hook failures so the stream can recover. - } - } - const runningStream = this.runStreamOnSession( - session, - connect, - listener, - () => active, - () => { - this.hasReportedTransportDisconnect = false; - hasReceivedValue = true; - }, - ); - cancelCurrentStream = runningStream.cancel; - await runningStream.completed; - cancelCurrentStream = NOOP; - } catch (error) { - cancelCurrentStream = NOOP; - if (!active || this.disposed) { - return; - } - - // Skip retry if the session has already been replaced by a reconnect. - if (session !== this.session) { - continue; - } - - const formattedError = formatErrorMessage(error); - if (!isTransportConnectionErrorMessage(formattedError)) { - this.logWarning("WebSocket RPC subscription failed", { error: formattedError }); - return; - } - - if (!this.hasReportedTransportDisconnect) { - this.logWarning("WebSocket RPC subscription disconnected", { - error: formattedError, - }); - } - this.hasReportedTransportDisconnect = true; - await sleep(retryDelayMs); - } - } - })(); - - return () => { - active = false; - this.streamRequestStartListeners.delete(onStreamRequestStart); - cancelCurrentStream(); - }; - } - - async reconnect() { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const reconnectOperation = this.reconnectChain.then(async () => { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - try { - this.options?.onBeforeReconnect?.(); - } catch { - // Ignore hook failures so reconnect can proceed. - } - - this.lastHeartbeatPongAt = null; - const previousSession = this.session; - this.session = this.createSession(); - await this.closeSession(previousSession); - }); - - this.reconnectChain = reconnectOperation.catch(() => undefined); - await reconnectOperation; - } - - isHeartbeatFresh(maxAgeMs = 15_000): boolean { - return ( - this.lastHeartbeatPongAt !== null && performance.now() - this.lastHeartbeatPongAt <= maxAgeMs - ); - } - - async dispose() { - if (this.disposed) { - return; - } - - this.disposed = true; - await this.closeSession(this.session); - } - - private closeSession(session: TransportSession) { - this.intentionalCloseDepth += 1; - return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { - this.intentionalCloseDepth = Math.max(0, this.intentionalCloseDepth - 1); - session.runtime.dispose(); - }); - } - - private createSession(): TransportSession { - const protocolFactory = this.options?.createProtocolLayer ?? createWsRpcProtocolLayer; - const sessionId = this.nextSessionId + 1; - this.nextSessionId = sessionId; - this.activeSessionId = sessionId; - const lifecycleHandlers = this.lifecycleHandlers; - const protocolLayer = protocolFactory(this.url, { - ...lifecycleHandlers, - isActive: () => - !this.disposed && - this.activeSessionId === sessionId && - (lifecycleHandlers?.isActive?.() ?? true), - isCloseIntentional: () => - this.disposed || - this.intentionalCloseDepth > 0 || - lifecycleHandlers?.isCloseIntentional?.() === true, - onHeartbeatPong: () => { - this.lastHeartbeatPongAt = performance.now(); - lifecycleHandlers?.onHeartbeatPong?.(); - }, - onRequestStart: (info) => { - lifecycleHandlers?.onRequestStart?.(info); - if (!info.stream) { - return; - } - for (const listener of this.streamRequestStartListeners) { - listener({ tag: info.tag }); - } - }, - }); - const rootLayer = this.options?.tracingLayer - ? Layer.mergeAll(protocolLayer, this.options.tracingLayer) - : protocolLayer; - const runtime = ManagedRuntime.make(rootLayer); - const clientScope = runtime.runSync(Scope.make()); - return { - runtime, - clientScope, - clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), - }; - } - - private logWarning(message: string, metadata: { readonly error: string }) { - const logWarning = this.options?.logWarning; - if (logWarning) { - logWarning(message, metadata); - } else { - Effect.runSync(Effect.logWarning(message, metadata)); - } - } - - private runStreamOnSession( - session: TransportSession, - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - isActive: () => boolean, - markValueReceived: () => void, - ): { - readonly cancel: () => void; - readonly completed: Promise; - } { - let resolveCompleted!: () => void; - let rejectCompleted!: (error: unknown) => void; - const completed = new Promise((resolve, reject) => { - resolveCompleted = resolve; - rejectCompleted = reject; - }); - const cancel = session.runtime.runCallback( - Effect.promise(() => session.clientPromise).pipe( - Effect.flatMap((client) => - Stream.runForEach(connect(client), (value) => - Effect.sync(() => { - if (!isActive()) { - return; - } - - markValueReceived(); - try { - listener(value); - } catch { - // Ignore listener errors so the stream stays live. - } - }), - ), - ), - ), - { - onExit: (exit) => { - if (Exit.isSuccess(exit)) { - resolveCompleted(); - return; - } - - rejectCompleted(Cause.squash(exit.cause)); - }, - }, - ); - - return { - cancel, - completed, - }; - } -} - -function sleep(ms: number): Promise { - return Effect.runPromise(Effect.sleep(Duration.millis(ms))); -} diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json index 73a306f847a..564a5990051 100644 --- a/packages/client-runtime/tsconfig.json +++ b/packages/client-runtime/tsconfig.json @@ -1,5 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": {}, "include": ["src"] } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index bd1ac0a53ec..0dbe7d9fa25 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -29,10 +29,170 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; -export class AssetAccessError extends Schema.TaggedErrorClass()( - "AssetAccessError", +export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( + "AssetWorkspaceContextNotFoundError", { - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), + resource: AssetResource, }, -) {} +) { + override get message(): string { + return "Workspace context was not found."; + } +} + +export class AssetWorkspaceContextResolutionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceContextResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve workspace context."; + } +} + +export class AssetWorkspaceRootNormalizationError extends Schema.TaggedErrorClass()( + "AssetWorkspaceRootNormalizationError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to normalize the workspace root."; + } +} + +export class AssetWorkspacePathValidationError extends Schema.TaggedErrorClass()( + "AssetWorkspacePathValidationError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Workspace file path must be relative to the project root."; + } +} + +export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass()( + "AssetPreviewTypeValidationError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Only browser documents and images can be previewed."; + } +} + +export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceAssetInspectionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to inspect the workspace asset."; + } +} + +export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass()( + "AssetWorkspaceAssetNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Workspace asset was not found."; + } +} + +export class AssetWorkspaceResolutionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve workspace."; + } +} + +export class AssetAttachmentNotFoundError extends Schema.TaggedErrorClass()( + "AssetAttachmentNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Attachment was not found."; + } +} + +export class AssetProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve project favicon."; + } +} + +export class AssetProjectFaviconInspectionError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconInspectionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to inspect the project favicon."; + } +} + +export class AssetProjectFaviconNotFoundError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Project favicon was not found."; + } +} + +export class AssetSigningKeyLoadError extends Schema.TaggedErrorClass()( + "AssetSigningKeyLoadError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load the asset signing key."; + } +} + +export const AssetAccessError = Schema.Union([ + AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, + AssetWorkspaceRootNormalizationError, + AssetWorkspacePathValidationError, + AssetPreviewTypeValidationError, + AssetWorkspaceAssetInspectionError, + AssetWorkspaceAssetNotFoundError, + AssetWorkspaceResolutionError, + AssetAttachmentNotFoundError, + AssetProjectFaviconResolutionError, + AssetProjectFaviconInspectionError, + AssetProjectFaviconNotFoundError, + AssetSigningKeyLoadError, +]); +export type AssetAccessError = typeof AssetAccessError.Type; diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index c180cf24294..5948d87e1d2 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; -export class ExternalLauncherError extends Schema.TaggedErrorClass()( - "ExternalLauncherError", +export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass()( + "ExternalLauncherUnknownEditorError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), + editor: Schema.String, }, -) {} +) { + override get message(): string { + return `Unknown editor: ${this.editor}`; + } +} + +export class ExternalLauncherUnsupportedEditorError extends Schema.TaggedErrorClass()( + "ExternalLauncherUnsupportedEditorError", + { + editor: EditorId, + }, +) { + override get message(): string { + return `Unsupported editor: ${this.editor}`; + } +} + +export class ExternalLauncherCommandNotFoundError extends Schema.TaggedErrorClass()( + "ExternalLauncherCommandNotFoundError", + { + editor: EditorId, + command: Schema.String, + }, +) { + override get message(): string { + return `Editor command not found: ${this.command}`; + } +} + +const ExternalLauncherSpawnFields = { + command: Schema.String, + args: Schema.Array(Schema.String), + cause: Schema.Defect(), +}; + +export class ExternalLauncherBrowserSpawnError extends Schema.TaggedErrorClass()( + "ExternalLauncherBrowserSpawnError", + { + ...ExternalLauncherSpawnFields, + target: Schema.String, + }, +) { + override get message(): string { + return `Failed to launch browser target '${this.target}' with '${[this.command, ...this.args].join(" ")}'`; + } +} + +export class ExternalLauncherEditorSpawnError extends Schema.TaggedErrorClass()( + "ExternalLauncherEditorSpawnError", + { + ...ExternalLauncherSpawnFields, + editor: EditorId, + target: Schema.String, + }, +) { + override get message(): string { + return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`; + } +} + +export const ExternalLauncherError = Schema.Union([ + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherEditorSpawnError, +]); +export type ExternalLauncherError = typeof ExternalLauncherError.Type; + +export const isExternalLauncherError = Schema.is(ExternalLauncherError); diff --git a/packages/contracts/src/filesystem.test.ts b/packages/contracts/src/filesystem.test.ts new file mode 100644 index 00000000000..45355b73edc --- /dev/null +++ b/packages/contracts/src/filesystem.test.ts @@ -0,0 +1,33 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { FilesystemBrowseError } from "./filesystem.ts"; + +describe("FilesystemBrowseError", () => { + it("derives a stable message from browse context while retaining the cause", () => { + const cause = new Error("sensitive filesystem detail"); + const error = new FilesystemBrowseError({ + cwd: "/workspace", + partialPath: "./src/mai", + failure: "read_directory_failed", + parentPath: "/workspace/src", + cause, + }); + + expect(error.message).toBe("Failed to browse filesystem path './src/mai' from '/workspace'."); + expect(error.message).not.toContain(cause.message); + expect(error.cause).toBe(cause); + }); + + it("decodes legacy message-only errors during rolling upgrades", () => { + const decodeError = Schema.decodeUnknownSync(FilesystemBrowseError); + const error = decodeError({ + _tag: "FilesystemBrowseError", + message: "Legacy filesystem browse failure.", + }); + + expect(error.message).toBe("Legacy filesystem browse failure."); + expect(error.partialPath).toBeUndefined(); + expect(error.failure).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 511f8ee19a3..ca4519b4c8b 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -21,10 +21,47 @@ export const FilesystemBrowseResult = Schema.Struct({ }); export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; +export const FilesystemBrowseFailure = Schema.Literals([ + "windows_path_unsupported", + "current_project_required", + "read_directory_failed", +]); +export type FilesystemBrowseFailure = typeof FilesystemBrowseFailure.Type; + +function decodedFilesystemBrowseErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class FilesystemBrowseError extends Schema.TaggedErrorClass()( "FilesystemBrowseError", { + partialPath: Schema.optional(TrimmedNonEmptyString), + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(FilesystemBrowseFailure), + parentPath: Schema.optional(TrimmedNonEmptyString), + platform: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // Structured diagnostics stay optional for rolling compatibility with legacy message-only + // payloads, while new call sites must provide the request context and failure classification. + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly partialPath: string; + readonly cwd?: string | undefined; + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; + readonly cause?: unknown; + }) { + const cwd = props.cwd === undefined ? "" : ` from '${props.cwd}'`; + super({ + ...props, + message: + decodedFilesystemBrowseErrorMessage(props) ?? + `Failed to browse filesystem path '${props.partialPath}'${cwd}.`, + } as any); + } +} diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 0a5497367cd..4ea86670ff8 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -28,6 +28,18 @@ describe("VcsCreateWorktreeInput", () => { expect(parsed.newRefName).toBeUndefined(); expect(parsed.refName).toBe("feature/existing"); }); + + it("accepts baseRefName metadata for a new worktree ref", () => { + const parsed = decodeCreateWorktreeInput({ + cwd: "/repo", + refName: "0123456789abcdef", + newRefName: "feature/new", + baseRefName: "origin/main", + path: "/tmp/worktree", + }); + + expect(parsed.baseRefName).toBe("origin/main"); + }); }); describe("GitPreparePullRequestThreadInput", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e8e9a4ecc1a..aa5cdf8432b 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -125,6 +125,8 @@ export const VcsListRefsInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), cursor: Schema.optional(NonNegativeInt), + includeMatchingRemoteRefs: Schema.optional(Schema.Boolean), + refKind: Schema.optional(Schema.Literals(["all", "local", "remote"])), limit: Schema.optional( PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), ), @@ -135,6 +137,7 @@ export const VcsCreateWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, newRefName: Schema.optional(TrimmedNonEmptyStringSchema), + baseRefName: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), }); export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; @@ -321,11 +324,16 @@ export class GitCommandError extends Schema.TaggedErrorClass()( operation: Schema.String, command: Schema.String, cwd: Schema.String, + argumentCount: Schema.optional(Schema.Number), + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + outputLength: Schema.optional(Schema.Number), detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { override get message(): string { - return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}`; } } @@ -344,6 +352,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass()("GitManagerError", { operation: Schema.String, + cwd: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { @@ -352,8 +361,25 @@ export class GitManagerError extends Schema.TaggedErrorClass()( } } +export class GitPullRequestMaterializationError extends Schema.TaggedErrorClass()( + "GitPullRequestMaterializationError", + { + cwd: TrimmedNonEmptyStringSchema, + pullRequestNumber: PositiveInt, + headRepository: Schema.NullOr(TrimmedNonEmptyStringSchema), + headBranch: TrimmedNonEmptyStringSchema, + localBranch: TrimmedNonEmptyStringSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to materialize pull request #${this.pullRequestNumber} branch ${this.headBranch} as ${this.localBranch}.`; + } +} + export const GitManagerServiceError = Schema.Union([ GitManagerError, + GitPullRequestMaterializationError, GitCommandError, SourceControlProviderError, TextGenerationError, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dce7c0709d3..9d6ed04c286 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -75,6 +75,7 @@ import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, PreviewAutomationOwner, + PreviewAutomationOwnerIdentity, PreviewAutomationPressInput, PreviewAutomationRequest, PreviewAutomationResponse, @@ -415,23 +416,6 @@ export const PickFolderOptionsSchema = Schema.Struct({ initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), }); -export const DesktopCloudAuthFetchInputSchema = Schema.Struct({ - url: Schema.String, - method: Schema.optionalKey(Schema.String), - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.optionalKey(Schema.String), -}); -export type DesktopCloudAuthFetchInput = typeof DesktopCloudAuthFetchInputSchema.Type; - -export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ - ok: Schema.Boolean, - status: Schema.Number, - statusText: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, -}); -export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; - /** * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process * PreviewTabState shape but uses serialisable primitives only. @@ -897,15 +881,12 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; + getLocalEnvironmentBearerToken: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; - getSavedEnvironmentRegistry: () => Promise; - setSavedEnvironmentRegistry: ( - records: readonly PersistedSavedEnvironmentRecord[], - ) => Promise; - getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; - setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; - removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; + getConnectionCatalog?: () => Promise; + setConnectionCatalog?: (catalog: string) => Promise; + clearConnectionCatalog?: () => Promise; discoverSshHosts: () => Promise; ensureSshEnvironment: ( target: DesktopSshEnvironmentTarget, @@ -939,12 +920,6 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; - createCloudAuthRequest: () => Promise; - getCloudAuthToken: () => Promise; - setCloudAuthToken: (token: string) => Promise; - clearCloudAuthToken: () => Promise; - fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise; - onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; @@ -1049,13 +1024,6 @@ export interface LocalApi { persistence: { getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; - getSavedEnvironmentRegistry: () => Promise; - setSavedEnvironmentRegistry: ( - records: readonly PersistedSavedEnvironmentRecord[], - ) => Promise; - getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; - setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; - removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; }; server: { getConfig: () => Promise; @@ -1198,7 +1166,7 @@ export interface EnvironmentApi { ) => () => void; respond: (response: PreviewAutomationResponse) => Promise; reportOwner: (owner: PreviewAutomationOwner) => Promise; - clearOwner: (input: { clientId: string }) => Promise; + clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise; }; onEvent: ( callback: (event: PreviewEvent) => void, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 19c98c390c3..33ecd38039f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsed.command, "terminal.toggle"); + const parsedSidebarToggle = yield* decode(KeybindingRule, { + key: "mod+b", + command: "sidebar.toggle", + }); + assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); + const parsedRightPanelToggle = yield* decode(KeybindingRule, { key: "mod+alt+b", command: "rightPanel.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 4a5ffd0c3dd..c7cff9943cd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -48,6 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; const STATIC_KEYBINDING_COMMANDS = [ + "sidebar.toggle", "terminal.toggle", "terminal.split", "terminal.splitVertical", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 3dc83933e38..29a732ca69b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -277,6 +277,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => projectCwd: "/tmp/workspace", baseBranch: "main", branch: "t3code/example", + startFromOrigin: true, }, runSetupScript: true, }, @@ -284,6 +285,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => }); assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); + assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true); assert.strictEqual(parsed.bootstrap?.runSetupScript, true); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3a164bf6689..e75a3bdb30a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -592,6 +592,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ projectCwd: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, branch: Schema.optional(TrimmedNonEmptyString), + startFromOrigin: Schema.optional(Schema.Boolean), }); const ThreadTurnStartBootstrap = Schema.Struct({ diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 044b8fbbd07..457e66ee07f 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -172,14 +172,15 @@ export class PreviewSessionLookupError extends Schema.TaggedErrorClass()( "PreviewInvalidUrlError", { - rawUrl: Schema.String, - detail: Schema.optional(Schema.String), + inputLength: Schema.Number, + reason: Schema.Literals(["empty", "parse", "unsupported-protocol", "unexpected"]), + protocol: Schema.optional(Schema.String), + cause: Schema.Defect(), }, ) { override get message() { - return this.detail - ? `Invalid preview URL: ${this.rawUrl} (${this.detail})` - : `Invalid preview URL: ${this.rawUrl}`; + const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`; + return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`; } } diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 791591a7a9b..118fb892737 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { PreviewTabId } from "./preview.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; const BoundedUrl = Schema.String.check(Schema.isTrimmed()) .check( @@ -410,10 +411,15 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({ }); export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; -export const PreviewAutomationOwner = Schema.Struct({ +export const PreviewAutomationOwnerIdentity = Schema.Struct({ clientId: TrimmedNonEmptyString, environmentId: EnvironmentId, threadId: ThreadId, +}); +export type PreviewAutomationOwnerIdentity = typeof PreviewAutomationOwnerIdentity.Type; + +export const PreviewAutomationOwner = Schema.Struct({ + ...PreviewAutomationOwnerIdentity.fields, tabId: Schema.NullOr(PreviewTabId), visible: Schema.Boolean, supportsAutomation: Schema.Boolean, @@ -447,48 +453,218 @@ export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass()( "PreviewAutomationUnavailableError", - { message: Schema.String }, -) {} + { + capability: Schema.Literal("preview"), + environmentId: EnvironmentId, + threadId: ThreadId, + providerSessionId: TrimmedNonEmptyString, + providerInstanceId: ProviderInstanceId, + }, +) { + override get message(): string { + return `MCP credential does not grant the ${this.capability} capability.`; + } +} + +const PreviewAutomationScopeErrorFields = { + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + providerSessionId: TrimmedNonEmptyString, + providerInstanceId: ProviderInstanceId, +}; + +const PreviewAutomationRequestErrorFields = { + ...PreviewAutomationScopeErrorFields, + clientId: TrimmedNonEmptyString, + requestId: TrimmedNonEmptyString, + tabId: Schema.optional(PreviewTabId), + timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), +}; + +const PreviewAutomationRemoteDiagnosticFields = { + remoteTag: TrimmedNonEmptyString, + remoteMessageLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + remoteDetailKind: Schema.optional( + Schema.Literals(["null", "array", "object", "string", "number", "boolean"]), + ), + cause: Schema.Defect(), +}; + +const PreviewAutomationOptionalRemoteDiagnosticFields = { + remoteTag: Schema.optional(TrimmedNonEmptyString), + remoteMessageLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + remoteDetailKind: Schema.optional( + Schema.Literals(["null", "array", "object", "string", "number", "boolean"]), + ), + cause: Schema.optional(Schema.Defect()), +}; export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass()( "PreviewAutomationNoFocusedOwnerError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationScopeErrorFields, + clientId: Schema.optional(TrimmedNonEmptyString), + requestId: Schema.optional(TrimmedNonEmptyString), + tabId: Schema.optional(PreviewTabId), + timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + ...PreviewAutomationOptionalRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = `No focused preview automation owner is available for ${this.operation} in thread ${this.threadId}.`; + return summary; + } +} export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass()( "PreviewAutomationUnsupportedClientError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation client ${this.clientId} does not support ${this.operation}.`; + } +} export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass()( "PreviewAutomationTabNotFoundError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = this.tabId + ? `Preview tab ${this.tabId} was not found for ${this.operation}.` + : `No active preview tab was found for ${this.operation}.`; + return summary; + } +} export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass()( "PreviewAutomationTimeoutError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationOptionalRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = `Preview automation ${this.operation} timed out after ${this.timeoutMs}ms.`; + return summary; + } +} export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass()( "PreviewAutomationControlInterruptedError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`; + } +} export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass()( "PreviewAutomationExecutionError", - { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} failed on client ${this.clientId}.`; + } +} export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass()( "PreviewAutomationInvalidSelectorError", - { message: Schema.String, selector: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])), + selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }, +) { + override get message(): string { + if (this.selectorKind !== undefined && this.selectorLength !== undefined) { + return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`; + } + return `Preview automation ${this.operation} received an invalid selector.`; + } +} export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()( "PreviewAutomationResultTooLargeError", - { message: Schema.String, maximumBytes: Schema.Int }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + maximumBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + }, +) { + override get message(): string { + const summary = + this.maximumBytes === undefined + ? `Preview automation ${this.operation} produced a result that is too large.` + : `Preview automation ${this.operation} produced a result larger than ${this.maximumBytes} bytes.`; + return summary; + } +} + +export class PreviewAutomationHostNotConnectedError extends Schema.TaggedErrorClass()( + "PreviewAutomationHostNotConnectedError", + { + ...PreviewAutomationScopeErrorFields, + clientId: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Preview automation host ${this.clientId} is not connected for ${this.operation}.`; + } +} + +export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass()( + "PreviewAutomationClientDisconnectedError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} disconnected during ${this.operation}.`; + } +} + +export class PreviewAutomationRequestQueueClosedError extends Schema.TaggedErrorClass()( + "PreviewAutomationRequestQueueClosedError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} stopped accepting ${this.operation} requests.`; + } +} + +export class PreviewAutomationRemoteUnavailableError extends Schema.TaggedErrorClass()( + "PreviewAutomationRemoteUnavailableError", + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} is unavailable on client ${this.clientId}.`; + } +} + +export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorClass()( + "PreviewAutomationMalformedResponseError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} returned a malformed response for ${this.operation}.`; + } +} export const PreviewAutomationError = Schema.Union([ PreviewAutomationUnavailableError, @@ -500,6 +676,11 @@ export const PreviewAutomationError = Schema.Union([ PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationResultTooLargeError, + PreviewAutomationHostNotConnectedError, + PreviewAutomationClientDisconnectedError, + PreviewAutomationRequestQueueClosedError, + PreviewAutomationRemoteUnavailableError, + PreviewAutomationMalformedResponseError, ]); export type PreviewAutomationError = typeof PreviewAutomationError.Type; diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts new file mode 100644 index 00000000000..ea9d5a90e7c --- /dev/null +++ b/packages/contracts/src/project.test.ts @@ -0,0 +1,67 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ProjectReadFileError, + ProjectSearchEntriesError, + ProjectWriteFileError, +} from "./project.ts"; + +describe("project RPC errors", () => { + it("derives stable messages from structured request context while retaining causes", () => { + const cause = new Error("sensitive platform detail"); + const searchError = new ProjectSearchEntriesError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 20, + failure: "search_index_search_failed", + normalizedCwd: "/workspace", + detail: "index unavailable", + cause, + }); + const readError = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "src/index.ts", + failure: "operation_failed", + operation: "read", + operationPath: "/workspace/src/index.ts", + resolvedPath: "/workspace/src/index.ts", + cause, + }); + + expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'."); + expect(searchError.message).not.toContain(cause.message); + expect(searchError.normalizedCwd).toBe("/workspace"); + expect(searchError.queryLength).toBe("authorization: Bearer secret-token".length); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(searchError.cause).toBe(cause); + expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); + expect(readError.message).not.toContain(cause.message); + expect(readError.cause).toBe(cause); + }); + + it("decodes legacy message-only errors during rolling upgrades", () => { + const decodeSearchError = Schema.decodeUnknownSync(ProjectSearchEntriesError); + const decodeWriteError = Schema.decodeUnknownSync(ProjectWriteFileError); + + const searchError = decodeSearchError({ + _tag: "ProjectSearchEntriesError", + message: "Legacy project search failure.", + query: "legacy sensitive query", + }); + const writeError = decodeWriteError({ + _tag: "ProjectWriteFileError", + message: "Legacy project write failure.", + }); + + expect(searchError.message).toBe("Legacy project search failure."); + expect(searchError.cwd).toBeUndefined(); + expect(searchError.queryLength).toBeUndefined(); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.failure).toBeUndefined(); + expect(writeError.message).toBe("Legacy project write failure."); + expect(writeError.relativePath).toBeUndefined(); + expect(writeError.failure).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 29610845288..d59b9770ad3 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -37,21 +37,84 @@ export const ProjectListEntriesResult = Schema.Struct({ }); export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; +export const ProjectEntriesFailure = Schema.Literals([ + "workspace_root_not_found", + "workspace_root_create_failed", + "workspace_root_stat_failed", + "workspace_root_not_directory", + "search_index_create_failed", + "search_index_scan_timed_out", + "search_index_search_failed", +]); +export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; + +type ProjectEntriesFailureContext = { + readonly failure: ProjectEntriesFailure; + readonly normalizedCwd?: string; + readonly timeout?: string; + readonly detail?: string; + readonly cause?: unknown; +}; + +function decodedProjectErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( "ProjectSearchEntriesError", { + cwd: Schema.optional(TrimmedNonEmptyString), + queryLength: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // The structured fields are optional on the wire so newer peers can decode legacy message-only + // failures. New application code must provide them through this constructor. + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: ProjectEntriesFailureContext & { + readonly cwd: string; + readonly queryLength: number; + readonly limit: number; + }, + ) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to search workspace entries in '${props.cwd}'.`, + } as any); + } +} export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`, + } as any); + } +} export const ProjectReadFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -67,13 +130,62 @@ export const ProjectReadFileResult = Schema.Struct({ }); export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; +export const ProjectFileFailure = Schema.Literals([ + "workspace_path_outside_root", + "resolved_path_outside_root", + "path_not_file", + "binary_file", + "operation_failed", +]); +export type ProjectFileFailure = typeof ProjectFileFailure.Type; + +export const ProjectFileOperation = Schema.Literals([ + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file", +]); +export type ProjectFileOperation = typeof ProjectFileOperation.Type; + +type ProjectFileFailureContext = { + readonly cwd: string; + readonly relativePath: string; + readonly failure: ProjectFileFailure; + readonly resolvedPath?: string; + readonly resolvedWorkspaceRoot?: string; + readonly operation?: ProjectFileOperation; + readonly operationPath?: string; + readonly cause?: unknown; +}; + export class ProjectReadFileError extends Schema.TaggedErrorClass()( "ProjectReadFileError", { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectFileFailure), + resolvedPath: Schema.optional(TrimmedNonEmptyString), + resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), + operation: Schema.optional(ProjectFileOperation), + operationPath: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectFileFailureContext) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`, + } as any); + } +} export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -90,7 +202,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; export class ProjectWriteFileError extends Schema.TaggedErrorClass()( "ProjectWriteFileError", { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectFileFailure), + resolvedPath: Schema.optional(TrimmedNonEmptyString), + resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), + operation: Schema.optional(ProjectFileOperation), + operationPath: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectFileFailureContext) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`, + } as any); + } +} diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 5c8cc1ad001..dea3709f488 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -1,5 +1,5 @@ -import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; +import * as Schema from "effect/Schema"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; @@ -512,26 +512,22 @@ const RelayAgentActivityPublishErrors = [ RelayInternalError, ] as const; -export interface RelayClientPrincipalShape { - readonly userId: string; - readonly token: string; - readonly proofKeyThumbprint?: string; - readonly dpopScopes?: ReadonlyArray; -} - export class RelayClientPrincipal extends Context.Service< RelayClientPrincipal, - RelayClientPrincipalShape + { + readonly userId: string; + readonly token: string; + readonly proofKeyThumbprint?: string; + readonly dpopScopes?: ReadonlyArray; + } >()("@t3tools/contracts/relay/RelayClientPrincipal") {} -export interface RelayEnvironmentPrincipalShape { - readonly environmentId: string; - readonly environmentPublicKey: string; -} - export class RelayEnvironmentPrincipal extends Context.Service< RelayEnvironmentPrincipal, - RelayEnvironmentPrincipalShape + { + readonly environmentId: string; + readonly environmentPublicKey: string; + } >()("@t3tools/contracts/relay/RelayEnvironmentPrincipal") {} const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( @@ -711,6 +707,7 @@ export const RelayEnvironmentStatusResponse = Schema.Struct({ checkedAt: TrimmedNonEmptyString, descriptor: Schema.optional(ExecutionEnvironmentDescriptor), error: Schema.optional(TrimmedNonEmptyString), + traceId: Schema.optional(TrimmedNonEmptyString), }); export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type; diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index 363b124bf22..a6b879a0c7f 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -6,6 +6,7 @@ import { VcsError } from "./vcs.ts"; export const ReviewDiffPreviewInput = Schema.Struct({ cwd: TrimmedNonEmptyString, baseRef: Schema.optional(TrimmedNonEmptyString), + ignoreWhitespace: Schema.optionalKey(Schema.Boolean), }); export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 87c5a49c73b..a2a8e9106aa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -108,6 +108,7 @@ import { import { PreviewAutomationError, PreviewAutomationOwner, + PreviewAutomationOwnerIdentity, PreviewAutomationRequest, PreviewAutomationResponse, } from "./previewAutomation.ts"; @@ -549,7 +550,7 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, }); export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { - payload: Schema.Struct({ clientId: Schema.String }), + payload: PreviewAutomationOwner, success: PreviewAutomationRequest, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), stream: true, @@ -566,7 +567,7 @@ export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAuto }); export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, { - payload: Schema.Struct({ clientId: Schema.String }), + payload: PreviewAutomationOwnerIdentity, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1aa280ad63b..b76ea965afe 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -364,6 +364,16 @@ export const ServerProcessResourceHistorySummary = Schema.Struct({ }); export type ServerProcessResourceHistorySummary = typeof ServerProcessResourceHistorySummary.Type; +export const ServerProcessResourceHistoryFailureTag = Schema.Literals([ + "ProcessDiagnosticsQueryTimeoutError", + "ProcessDiagnosticsQueryFailedError", + "ProcessDiagnosticsServerProcessSignalError", + "ProcessDiagnosticsNotDescendantError", + "ProcessDiagnosticsSignalFailedError", +]); +export type ServerProcessResourceHistoryFailureTag = + typeof ServerProcessResourceHistoryFailureTag.Type; + export const ServerProcessResourceHistoryResult = Schema.Struct({ readAt: Schema.DateTimeUtc, windowMs: NonNegativeInt, @@ -375,6 +385,7 @@ export const ServerProcessResourceHistoryResult = Schema.Struct({ topProcesses: Schema.Array(ServerProcessResourceHistorySummary), error: Schema.Option( Schema.Struct({ + failureTag: ServerProcessResourceHistoryFailureTag, message: TrimmedNonEmptyString, }), ), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 04ee479bcd3..ac2d47ca336 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,12 +2,35 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { + ClientSettingsSchema, + DEFAULT_SERVER_SETTINGS, + ServerSettings, + ServerSettingsPatch, +} from "./settings.ts"; +const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("ClientSettings word wrap", () => { + it("defaults word wrap on", () => { + expect(decodeClientSettings({}).wordWrap).toBe(true); + }); + + it("ignores obsolete wrapping preferences", () => { + const decoded = decodeClientSettings({ + chatWordWrap: false, + diffWordWrap: false, + }); + + expect(decoded.wordWrap).toBe(true); + expect(decoded).not.toHaveProperty("chatWordWrap"); + expect(decoded).not.toHaveProperty("diffWordWrap"); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); @@ -64,6 +87,18 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); }); +describe("ServerSettings worktree defaults", () => { + it("defaults start-from-origin off for legacy configs", () => { + expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false); + }); + + it("accepts start-from-origin updates", () => { + expect( + decodeServerSettingsPatch({ newWorktreesStartFromOrigin: true }).newWorktreesStartFromOrigin, + ).toBe(true); + }); +}); + describe("ServerSettingsPatch.providerInstances", () => { it("treats providerInstances as an optional whole-map replacement", () => { const patch = decodeServerSettingsPatch({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 17a74014c34..9baa368a2c9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -54,7 +54,6 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -99,6 +98,7 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -372,6 +372,7 @@ export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), automaticGitFetchInterval: Schema.DurationFromMillis.pipe( Schema.withDecodingDefault( Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), @@ -380,6 +381,9 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + newWorktreesStartFromOrigin: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -417,16 +421,37 @@ export type ServerSettings = typeof ServerSettings.Type; export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); +export const ServerSettingsOperation = Schema.Literals([ + "normalize", + "check-exists", + "read-file", + "read-secret", + "remove-secret", + "remove-stale-secret", + "write-secret", + "write-file", + "prepare-directory", +]); +export type ServerSettingsOperation = typeof ServerSettingsOperation.Type; + export class ServerSettingsError extends Schema.TaggedErrorClass()( "ServerSettingsError", { settingsPath: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: ServerSettingsOperation, + providerInstanceId: Schema.optional(Schema.String), + environmentVariable: Schema.optional(Schema.String), + cause: Schema.Defect(), }, ) { override get message(): string { - return `Server settings error at ${this.settingsPath}: ${this.detail}`; + const provider = + this.providerInstanceId === undefined ? "" : ` for provider ${this.providerInstanceId}`; + const variable = + this.environmentVariable === undefined + ? "" + : ` and environment variable ${this.environmentVariable}`; + return `Server settings ${this.operation} failed${provider}${variable} at ${this.settingsPath}.`; } } @@ -486,8 +511,10 @@ const OpenCodeSettingsPatch = Schema.Struct({ export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( @@ -519,7 +546,6 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadDelete: Schema.optionalKey(Schema.Boolean), contextMenuStyle: Schema.optionalKey(ContextMenuStyle), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), - diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ @@ -549,5 +575,6 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), + wordWrap: Schema.optionalKey(Schema.Boolean), }); export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 0ecf13c67e6..104aadd9161 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -155,6 +155,10 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass()( - "TerminalCwdError", +export class TerminalCwdNotFoundError extends Schema.TaggedErrorClass()( + "TerminalCwdNotFoundError", + { + cwd: Schema.String, + }, +) { + override get message() { + return `Terminal cwd does not exist: ${this.cwd}`; + } +} + +export class TerminalCwdNotDirectoryError extends Schema.TaggedErrorClass()( + "TerminalCwdNotDirectoryError", { cwd: Schema.String, - reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]), - cause: Schema.optional(Schema.Defect()), }, ) { override get message() { - if (this.reason === "notDirectory") { - return `Terminal cwd is not a directory: ${this.cwd}`; - } - if (this.reason === "notFound") { - return `Terminal cwd does not exist: ${this.cwd}`; - } - const causeMessage = - this.cause !== undefined && - this.cause !== null && - typeof this.cause === "object" && - "message" in this.cause - ? this.cause.message - : undefined; - return typeof causeMessage === "string" && causeMessage.length > 0 - ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})` - : `Failed to access terminal cwd: ${this.cwd}`; + return `Terminal cwd is not a directory: ${this.cwd}`; } } +export class TerminalCwdStatError extends Schema.TaggedErrorClass()( + "TerminalCwdStatError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to access terminal cwd: ${this.cwd}`; + } +} + +export const TerminalCwdError = Schema.Union([ + TerminalCwdNotFoundError, + TerminalCwdNotDirectoryError, + TerminalCwdStatError, +]); +export type TerminalCwdError = typeof TerminalCwdError.Type; + export class TerminalHistoryError extends Schema.TaggedErrorClass()( "TerminalHistoryError", { @@ -306,10 +319,42 @@ export class TerminalNotRunningError extends Schema.TaggedErrorClass()( + "TerminalWriteError", + { + threadId: Schema.String, + terminalId: Schema.String, + terminalPid: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to write to terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid}`; + } +} + +export class TerminalResizeError extends Schema.TaggedErrorClass()( + "TerminalResizeError", + { + threadId: Schema.String, + terminalId: Schema.String, + terminalPid: Schema.Number, + cols: TerminalColsSchema, + rows: TerminalRowsSchema, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to resize terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid} to ${this.cols}x${this.rows}`; + } +} + export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, TerminalNotRunningError, + TerminalWriteError, + TerminalResizeError, ]); export type TerminalError = typeof TerminalError.Type; diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 40deeb77da6..c1090f4f39a 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); export type VcsDriverKind = typeof VcsDriverKind.Type; @@ -62,28 +62,28 @@ export interface VcsProcessErrorContext { readonly operation: string; readonly command: string; readonly cwd: string; + readonly argumentCount?: number; } export interface VcsProcessSpawnFailure { readonly cause: unknown; } -export interface VcsProcessStdinFailure { - readonly cause: unknown; -} - -export interface VcsProcessReadFailure { - readonly stream: "stdout" | "stderr" | "exitCode"; - readonly cause: unknown; +export interface VcsProcessTimeoutFailure { + readonly timeoutMs: number; } -export interface VcsProcessOutputLimitFailure { - readonly stream: "stdout" | "stderr"; - readonly maxBytes: number; -} +export const VcsProcessExitFailureKind = Schema.Literals([ + "authentication", + "not-found", + "command-failed", +]); +export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type; -export interface VcsProcessTimeoutFailure { - readonly timeoutMs: number; +export interface VcsProcessExitFailure { + readonly exitCode: number; + readonly stderr: string; + readonly stderrTruncated: boolean; } export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( @@ -92,6 +92,7 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( @@ -128,6 +159,7 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass()( - "VcsOutputDecodeError", +const VcsProcessBoundaryErrorFields = { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + argumentCount: Schema.optional(NonNegativeInt), +}; + +export class VcsProcessStdinWriteError extends Schema.TaggedErrorClass()( + "VcsProcessStdinWriteError", { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + ...VcsProcessBoundaryErrorFields, + stdinBytes: NonNegativeInt, + cause: Schema.Defect(), }, ) { override get message(): string { - return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } - - static fromProcessStdinError(context: VcsProcessErrorContext, error: VcsProcessStdinFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: "failed to write process stdin", - cause: error.cause, - }); + return `VCS process failed to write ${this.stdinBytes} bytes to stdin in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: - error.stream === "exitCode" - ? "failed to read process exit code" - : `failed to read process ${error.stream}`, - cause: error.cause, - }); +export class VcsProcessOutputReadError extends Schema.TaggedErrorClass()( + "VcsProcessOutputReadError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr", "exitCode"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `VCS process failed to read ${this.stream} in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessOutputLimitError( - context: VcsProcessErrorContext, - error: VcsProcessOutputLimitFailure, - ) { - return new VcsOutputDecodeError({ - ...context, - detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, - }); +export class VcsProcessOutputLimitError extends Schema.TaggedErrorClass()( + "VcsProcessOutputLimitError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr"]), + maxBytes: NonNegativeInt, + observedBytes: NonNegativeInt, + }, +) { + override get message(): string { + return `VCS process ${this.stream} produced ${this.observedBytes} bytes in ${this.operation}: ${this.command} (${this.cwd}), exceeding the ${this.maxBytes} byte limit`; } +} - static missingExitCode(context: VcsProcessErrorContext) { - return new VcsOutputDecodeError({ - ...context, - detail: "process completed without an exit code", - }); +export class VcsProcessMissingExitCodeError extends Schema.TaggedErrorClass()( + "VcsProcessMissingExitCodeError", + VcsProcessBoundaryErrorFields, +) { + override get message(): string { + return `VCS process completed without an exit code in ${this.operation}: ${this.command} (${this.cwd})`; } } +export const VcsOutputDecodeError = Schema.Union([ + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, +]); +export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type; + export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass()( "VcsRepositoryDetectionError", { @@ -225,7 +270,10 @@ export const VcsError = Schema.Union([ VcsProcessSpawnError, VcsProcessExitError, VcsProcessTimeoutError, - VcsOutputDecodeError, + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, ]); diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index 937d931c404..f54f81e2a08 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -1,30 +1,29 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { RpcClientError } from "effect/unstable/rpc"; import * as AcpSchema from "../_generated/schema.gen.ts"; import * as AcpError from "../errors.ts"; const isError = Schema.is(AcpSchema.Error); -const isAcpRequestError = Schema.is(AcpError.AcpRequestError); - -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); export const callRpc = ( + method: string, effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTag("RpcClientError", (error) => - Effect.fail( - new AcpError.AcpTransportError({ - detail: error.message, - cause: error, - }), - ), - ), Effect.catchIf(isError, (error) => - Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)), + Effect.fail(AcpError.AcpRequestError.fromProtocolError(error, { method })), ), + Effect.catchTags({ + RpcClientError: (cause) => + Effect.fail( + new AcpError.AcpTransportError({ + operation: "call-rpc", + method, + cause, + }), + ), + }), ); export const runHandler = Effect.fnUntraced(function* ( @@ -37,9 +36,7 @@ export const runHandler = Effect.fnUntraced(function* ( } return yield* handler(payload).pipe( Effect.mapError((error) => - isAcpRequestError(error) - ? error.toProtocolError() - : AcpError.AcpRequestError.internalError(error.message).toProtocolError(), + AcpError.AcpRequestError.fromCoreHandlerError(error, method).toProtocolError(), ), ); }); @@ -51,12 +48,7 @@ export function decodeExtRequestRegistration( ) { return (params: unknown): Effect.Effect => Schema.decodeUnknownEffect(payload)(params).pipe( - Effect.mapError((error) => - AcpError.AcpRequestError.invalidParams( - `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, - { issue: error.issue }, - ), - ), + Effect.mapError((error) => AcpError.AcpRequestError.invalidExtensionPayload(method, error)), Effect.flatMap((decoded) => handler(decoded)), ); } @@ -68,12 +60,12 @@ export function decodeExtNotificationRegistration( ) { return (params: unknown): Effect.Effect => Schema.decodeUnknownEffect(payload)(params).pipe( - Effect.mapError( - (error) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`, - cause: error, - }), + Effect.mapError((error) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + method, + error, + ), ), Effect.flatMap((decoded) => handler(decoded)), ); diff --git a/packages/effect-acp/src/_internal/stdio.test.ts b/packages/effect-acp/src/_internal/stdio.test.ts new file mode 100644 index 00000000000..8a4171f7a8d --- /dev/null +++ b/packages/effect-acp/src/_internal/stdio.test.ts @@ -0,0 +1,45 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as AcpError from "../errors.ts"; +import { makeTerminationError } from "./stdio.ts"; + +describe("ACP child process termination", () => { + it.effect("retains the process identifier with the exit code", () => + Effect.gen(function* () { + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(41), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(7)), + }); + + assert.instanceOf(error, AcpError.AcpProcessExitedError); + assert.equal(error.pid, 41); + assert.equal(error.code, 7); + assert.equal(error.message, "ACP process exited with code 7"); + }), + ); + + it.effect("retains the process identifier and exact exit-status cause", () => + Effect.gen(function* () { + const rootCause = new Error("private process diagnostics"); + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + cause: rootCause, + }); + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Effect.fail(cause), + }); + + assert.instanceOf(error, AcpError.AcpTransportError); + assert.equal(error.pid, 42); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "ACP transport operation read-process-exit-status failed."); + assert.notInclude(error.message, rootCause.message); + }), + ); +}); diff --git a/packages/effect-acp/src/_internal/stdio.ts b/packages/effect-acp/src/_internal/stdio.ts index 8ddb4d37d0f..87af633637a 100644 --- a/packages/effect-acp/src/_internal/stdio.ts +++ b/packages/effect-acp/src/_internal/stdio.ts @@ -44,14 +44,20 @@ export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () { }; }); +type ChildProcessTerminationHandle = Pick< + ChildProcessSpawner.ChildProcessHandle, + "exitCode" | "pid" +>; + export const makeTerminationError = ( - handle: ChildProcessSpawner.ChildProcessHandle, + handle: ChildProcessTerminationHandle, ): Effect.Effect => Effect.match(handle.exitCode, { onFailure: (cause) => new AcpError.AcpTransportError({ - detail: "Failed to determine ACP process exit status", + operation: "read-process-exit-status", + pid: handle.pid, cause, }), - onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }), + onSuccess: (code) => new AcpError.AcpProcessExitedError({ code, pid: handle.pid }), }); diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 67bcd0f4c6d..307028b0a80 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -27,189 +27,190 @@ export interface AcpAgentOptions { readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; } -export interface AcpAgentShape { - readonly raw: { +export class AcpAgent extends Context.Service< + AcpAgent, + { + readonly raw: { + /** + * Stream of inbound ACP notifications observed on the connection. + */ + readonly notifications: Stream.Stream; + /** + * Sends a generic ACP extension request. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly request: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends a generic ACP extension notification. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly notify: (method: string, payload: unknown) => Effect.Effect; + }; + readonly client: { + /** + * Requests client permission for an operation. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + readonly requestPermission: ( + payload: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + /** + * Requests structured user input from the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly elicit: ( + payload: AcpSchema.ElicitationRequest, + ) => Effect.Effect; + /** + * Requests file contents from the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly readTextFile: ( + payload: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + /** + * Writes a text file through the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly writeTextFile: ( + payload: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect; + /** + * Creates a terminal on the client side. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly createTerminal: ( + payload: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + /** + * Sends a `session/update` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly sessionUpdate: ( + payload: AcpSchema.SessionNotification, + ) => Effect.Effect; + /** + * Sends a `session/elicitation/complete` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly elicitationComplete: ( + payload: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect; + /** + * Sends an ACP extension request to the client. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extRequest: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends an ACP extension notification to the client. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extNotification: ( + method: string, + payload: unknown, + ) => Effect.Effect; + }; /** - * Stream of inbound ACP notifications observed on the connection. + * Registers a handler for `initialize`. + * @see https://agentclientprotocol.com/protocol/schema#initialize */ - readonly notifications: Stream.Stream; + readonly handleInitialize: ( + handler: ( + request: AcpSchema.InitializeRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Sends a generic ACP extension request. - * @see https://agentclientprotocol.com/protocol/extensibility + * Registers a handler for `authenticate`. + * @see https://agentclientprotocol.com/protocol/schema#authenticate */ - readonly request: ( - method: string, - payload: unknown, - ) => Effect.Effect; - /** - * Sends a generic ACP extension notification. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly notify: (method: string, payload: unknown) => Effect.Effect; - }; - readonly client: { - /** - * Requests client permission for an operation. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ - readonly requestPermission: ( - payload: AcpSchema.RequestPermissionRequest, - ) => Effect.Effect; - /** - * Requests structured user input from the client. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ - readonly elicit: ( - payload: AcpSchema.ElicitationRequest, - ) => Effect.Effect; - /** - * Requests file contents from the client. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ - readonly readTextFile: ( - payload: AcpSchema.ReadTextFileRequest, - ) => Effect.Effect; + readonly handleAuthenticate: ( + handler: ( + request: AcpSchema.AuthenticateRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleLogout: ( + handler: ( + request: AcpSchema.LogoutRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleCreateSession: ( + handler: ( + request: AcpSchema.NewSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleLoadSession: ( + handler: ( + request: AcpSchema.LoadSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleListSessions: ( + handler: ( + request: AcpSchema.ListSessionsRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleForkSession: ( + handler: ( + request: AcpSchema.ForkSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleResumeSession: ( + handler: ( + request: AcpSchema.ResumeSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleCloseSession: ( + handler: ( + request: AcpSchema.CloseSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleSetSessionModel: ( + handler: ( + request: AcpSchema.SetSessionModelRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleSetSessionConfigOption: ( + handler: ( + request: AcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handlePrompt: ( + handler: ( + request: AcpSchema.PromptRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Writes a text file through the client. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + * Registers a handler for `session/cancel`. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel */ - readonly writeTextFile: ( - payload: AcpSchema.WriteTextFileRequest, - ) => Effect.Effect; - /** - * Creates a terminal on the client side. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create - */ - readonly createTerminal: ( - payload: AcpSchema.CreateTerminalRequest, - ) => Effect.Effect; - /** - * Sends a `session/update` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ - readonly sessionUpdate: ( - payload: AcpSchema.SessionNotification, - ) => Effect.Effect; - /** - * Sends a `session/elicitation/complete` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete - */ - readonly elicitationComplete: ( - payload: AcpSchema.ElicitationCompleteNotification, - ) => Effect.Effect; - /** - * Sends an ACP extension request to the client. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly extRequest: ( + readonly handleCancel: ( + handler: ( + notification: AcpSchema.CancelNotification, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownExtRequest: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownExtNotification: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + readonly handleExtRequest: ( method: string, - payload: unknown, - ) => Effect.Effect; - /** - * Sends an ACP extension notification to the client. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly extNotification: ( + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + readonly handleExtNotification: ( method: string, - payload: unknown, - ) => Effect.Effect; - }; - /** - * Registers a handler for `initialize`. - * @see https://agentclientprotocol.com/protocol/schema#initialize - */ - readonly handleInitialize: ( - handler: ( - request: AcpSchema.InitializeRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `authenticate`. - * @see https://agentclientprotocol.com/protocol/schema#authenticate - */ - readonly handleAuthenticate: ( - handler: ( - request: AcpSchema.AuthenticateRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleLogout: ( - handler: ( - request: AcpSchema.LogoutRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleCreateSession: ( - handler: ( - request: AcpSchema.NewSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleLoadSession: ( - handler: ( - request: AcpSchema.LoadSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleListSessions: ( - handler: ( - request: AcpSchema.ListSessionsRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleForkSession: ( - handler: ( - request: AcpSchema.ForkSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleResumeSession: ( - handler: ( - request: AcpSchema.ResumeSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleCloseSession: ( - handler: ( - request: AcpSchema.CloseSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleSetSessionModel: ( - handler: ( - request: AcpSchema.SetSessionModelRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleSetSessionConfigOption: ( - handler: ( - request: AcpSchema.SetSessionConfigOptionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handlePrompt: ( - handler: ( - request: AcpSchema.PromptRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `session/cancel`. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel - */ - readonly handleCancel: ( - handler: (notification: AcpSchema.CancelNotification) => Effect.Effect, - ) => Effect.Effect; - readonly handleUnknownExtRequest: ( - handler: (method: string, params: unknown) => Effect.Effect, - ) => Effect.Effect; - readonly handleUnknownExtNotification: ( - handler: (method: string, params: unknown) => Effect.Effect, - ) => Effect.Effect; - readonly handleExtRequest: ( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, - ) => Effect.Effect; - readonly handleExtNotification: ( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, - ) => Effect.Effect; -} - -export class AcpAgent extends Context.Service()( - "effect-acp/agent/AcpAgent", -) {} + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + } +>()("effect-acp/agent/AcpAgent") {} interface AcpCoreAgentRequestHandlers { initialize?: ( @@ -255,7 +256,7 @@ const decodeCancelNotification = Schema.decodeUnknownEffect(AcpSchema.CancelNoti export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( stdio: Stdio.Stdio, options: AcpAgentOptions = {}, -): Effect.fn.Return { +): Effect.fn.Return { const coreHandlers: AcpCoreAgentRequestHandlers = {}; const cancelHandlers: Array< (notification: AcpSchema.CancelNotification) => Effect.Effect @@ -287,12 +288,12 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( notification.method === AGENT_METHODS.session_cancel ) { return decodeCancelNotification(notification.params).pipe( - Effect.mapError( - (error) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${AGENT_METHODS.session_cancel} notification payload`, - cause: error, - }), + Effect.mapError((error) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + AGENT_METHODS.session_cancel, + error, + ), ), Effect.flatMap((decoded) => Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }), @@ -375,41 +376,55 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( }, client: { requestPermission: (payload) => - callRpc(rpc[CLIENT_METHODS.session_request_permission](payload)), - elicit: (payload) => callRpc(rpc[CLIENT_METHODS.session_elicitation](payload)), - readTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_read_text_file](payload)), - writeTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_write_text_file](payload)), + callRpc( + CLIENT_METHODS.session_request_permission, + rpc[CLIENT_METHODS.session_request_permission](payload), + ), + elicit: (payload) => + callRpc( + CLIENT_METHODS.session_elicitation, + rpc[CLIENT_METHODS.session_elicitation](payload), + ), + readTextFile: (payload) => + callRpc(CLIENT_METHODS.fs_read_text_file, rpc[CLIENT_METHODS.fs_read_text_file](payload)), + writeTextFile: (payload) => + callRpc(CLIENT_METHODS.fs_write_text_file, rpc[CLIENT_METHODS.fs_write_text_file](payload)), createTerminal: (payload) => - callRpc(rpc[CLIENT_METHODS.terminal_create](payload)).pipe( - Effect.map((response) => - AcpTerminal.makeTerminal({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - output: callRpc( - rpc[CLIENT_METHODS.terminal_output]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - waitForExit: callRpc( - rpc[CLIENT_METHODS.terminal_wait_for_exit]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - kill: callRpc( - rpc[CLIENT_METHODS.terminal_kill]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - release: callRpc( - rpc[CLIENT_METHODS.terminal_release]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - }), + callRpc(CLIENT_METHODS.terminal_create, rpc[CLIENT_METHODS.terminal_create](payload)).pipe( + Effect.map( + (response) => + ({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + output: callRpc( + CLIENT_METHODS.terminal_output, + rpc[CLIENT_METHODS.terminal_output]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + waitForExit: callRpc( + CLIENT_METHODS.terminal_wait_for_exit, + rpc[CLIENT_METHODS.terminal_wait_for_exit]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + kill: callRpc( + CLIENT_METHODS.terminal_kill, + rpc[CLIENT_METHODS.terminal_kill]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + release: callRpc( + CLIENT_METHODS.terminal_release, + rpc[CLIENT_METHODS.terminal_release]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + }) satisfies AcpTerminal.AcpTerminal, ), ), sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload), diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index aca87d45c62..c732f80ef35 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -147,7 +147,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { ); it.effect( - "returns formatted invalid params when a typed extension request payload is wrong", + "returns structured invalid params without exposing values from typed extension request payloads", () => Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" }); @@ -213,8 +213,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { assert.fail("Expected prompt to fail for invalid typed extension payload"); } const rendered = Cause.pretty(result.cause); - assert.include(rendered, "Invalid x/typed_request payload:"); - assert.include(rendered, "Expected string, got 123"); + assert.include(rendered, "Invalid payload for ACP extension method 'x/typed_request'."); + assert.notInclude(rendered, "Expected string, got 123"); }), ); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index c3a59c798c7..61b3d71b49d 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -7,7 +7,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as AcpError from "./errors.ts"; import * as AcpProtocol from "./protocol.ts"; @@ -34,237 +34,236 @@ type AcpClientRaw = { readonly notify: (method: string, payload: unknown) => Effect.Effect; }; -export interface AcpClientShape { - readonly raw: AcpClientRaw; - readonly agent: { +export class AcpClient extends Context.Service< + AcpClient, + { + readonly raw: AcpClientRaw; + readonly agent: { + /** + * Initializes the ACP session and negotiates capabilities. + * @see https://agentclientprotocol.com/protocol/schema#initialize + */ + readonly initialize: ( + payload: AcpSchema.InitializeRequest, + ) => Effect.Effect; + /** + * Performs ACP authentication when the agent requires it. + * @see https://agentclientprotocol.com/protocol/schema#authenticate + */ + readonly authenticate: ( + payload: AcpSchema.AuthenticateRequest, + ) => Effect.Effect; + /** + * Logs out the current ACP identity. + * @see https://agentclientprotocol.com/protocol/schema#logout + */ + readonly logout: ( + payload: AcpSchema.LogoutRequest, + ) => Effect.Effect; + /** + * Starts a new ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/new + */ + readonly createSession: ( + payload: AcpSchema.NewSessionRequest, + ) => Effect.Effect; + /** + * Loads a previously saved ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/load + */ + readonly loadSession: ( + payload: AcpSchema.LoadSessionRequest, + ) => Effect.Effect; + /** + * Lists available ACP sessions. + * @see https://agentclientprotocol.com/protocol/schema#session/list + */ + readonly listSessions: ( + payload: AcpSchema.ListSessionsRequest, + ) => Effect.Effect; + /** + * Forks an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/fork + */ + readonly forkSession: ( + payload: AcpSchema.ForkSessionRequest, + ) => Effect.Effect; + /** + * Resumes an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/resume + */ + readonly resumeSession: ( + payload: AcpSchema.ResumeSessionRequest, + ) => Effect.Effect; + /** + * Closes an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/close + */ + readonly closeSession: ( + payload: AcpSchema.CloseSessionRequest, + ) => Effect.Effect; + /** + * Selects the active model for a session. + * @see https://agentclientprotocol.com/protocol/schema#session/set_model + */ + readonly setSessionModel: ( + payload: AcpSchema.SetSessionModelRequest, + ) => Effect.Effect; + /** + * Updates a session configuration option. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setSessionConfigOption: ( + payload: AcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect; + /** + * Sends a prompt turn to the agent. + * @see https://agentclientprotocol.com/protocol/schema#session/prompt + */ + readonly prompt: ( + payload: AcpSchema.PromptRequest, + ) => Effect.Effect; + /** + * Sends a real ACP `session/cancel` notification. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel + */ + readonly cancel: ( + payload: AcpSchema.CancelNotification, + ) => Effect.Effect; + }; /** - * Initializes the ACP session and negotiates capabilities. - * @see https://agentclientprotocol.com/protocol/schema#initialize + * Registers a handler for `session/request_permission`. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission */ - readonly initialize: ( - payload: AcpSchema.InitializeRequest, - ) => Effect.Effect; + readonly handleRequestPermission: ( + handler: ( + request: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Performs ACP authentication when the agent requires it. - * @see https://agentclientprotocol.com/protocol/schema#authenticate + * Registers a handler for `session/elicitation`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation */ - readonly authenticate: ( - payload: AcpSchema.AuthenticateRequest, - ) => Effect.Effect; + readonly handleElicitation: ( + handler: ( + request: AcpSchema.ElicitationRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Logs out the current ACP identity. - * @see https://agentclientprotocol.com/protocol/schema#logout + * Registers a handler for `fs/read_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file */ - readonly logout: ( - payload: AcpSchema.LogoutRequest, - ) => Effect.Effect; + readonly handleReadTextFile: ( + handler: ( + request: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Starts a new ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/new + * Registers a handler for `fs/write_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file */ - readonly createSession: ( - payload: AcpSchema.NewSessionRequest, - ) => Effect.Effect; + readonly handleWriteTextFile: ( + handler: ( + request: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Loads a previously saved ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/load + * Registers a handler for `terminal/create`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create */ - readonly loadSession: ( - payload: AcpSchema.LoadSessionRequest, - ) => Effect.Effect; + readonly handleCreateTerminal: ( + handler: ( + request: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Lists available ACP sessions. - * @see https://agentclientprotocol.com/protocol/schema#session/list + * Registers a handler for `terminal/output`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/output */ - readonly listSessions: ( - payload: AcpSchema.ListSessionsRequest, - ) => Effect.Effect; + readonly handleTerminalOutput: ( + handler: ( + request: AcpSchema.TerminalOutputRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Forks an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/fork + * Registers a handler for `terminal/wait_for_exit`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit */ - readonly forkSession: ( - payload: AcpSchema.ForkSessionRequest, - ) => Effect.Effect; + readonly handleTerminalWaitForExit: ( + handler: ( + request: AcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Resumes an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/resume + * Registers a handler for `terminal/kill`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/kill */ - readonly resumeSession: ( - payload: AcpSchema.ResumeSessionRequest, - ) => Effect.Effect; + readonly handleTerminalKill: ( + handler: ( + request: AcpSchema.KillTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Closes an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/close + * Registers a handler for `terminal/release`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/release */ - readonly closeSession: ( - payload: AcpSchema.CloseSessionRequest, - ) => Effect.Effect; + readonly handleTerminalRelease: ( + handler: ( + request: AcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Selects the active model for a session. - * @see https://agentclientprotocol.com/protocol/schema#session/set_model + * Registers a handler for `session/update`. + * @see https://agentclientprotocol.com/protocol/schema#session/update */ - readonly setSessionModel: ( - payload: AcpSchema.SetSessionModelRequest, - ) => Effect.Effect; + readonly handleSessionUpdate: ( + handler: ( + notification: AcpSchema.SessionNotification, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Updates a session configuration option. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + * Registers a handler for `session/elicitation/complete`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete */ - readonly setSessionConfigOption: ( - payload: AcpSchema.SetSessionConfigOptionRequest, - ) => Effect.Effect; + readonly handleElicitationComplete: ( + handler: ( + notification: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Sends a prompt turn to the agent. - * @see https://agentclientprotocol.com/protocol/schema#session/prompt + * Registers a fallback extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly prompt: ( - payload: AcpSchema.PromptRequest, - ) => Effect.Effect; + readonly handleUnknownExtRequest: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; /** - * Sends a real ACP `session/cancel` notification. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel + * Registers a fallback extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly cancel: ( - payload: AcpSchema.CancelNotification, - ) => Effect.Effect; - }; - /** - * Registers a handler for `session/request_permission`. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ - readonly handleRequestPermission: ( - handler: ( - request: AcpSchema.RequestPermissionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `session/elicitation`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ - readonly handleElicitation: ( - handler: ( - request: AcpSchema.ElicitationRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `fs/read_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ - readonly handleReadTextFile: ( - handler: ( - request: AcpSchema.ReadTextFileRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `fs/write_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file - */ - readonly handleWriteTextFile: ( - handler: ( - request: AcpSchema.WriteTextFileRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/create`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create - */ - readonly handleCreateTerminal: ( - handler: ( - request: AcpSchema.CreateTerminalRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/output`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/output - */ - readonly handleTerminalOutput: ( - handler: ( - request: AcpSchema.TerminalOutputRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/wait_for_exit`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit - */ - readonly handleTerminalWaitForExit: ( - handler: ( - request: AcpSchema.WaitForTerminalExitRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/kill`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/kill - */ - readonly handleTerminalKill: ( - handler: ( - request: AcpSchema.KillTerminalRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/release`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/release - */ - readonly handleTerminalRelease: ( - handler: ( - request: AcpSchema.ReleaseTerminalRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `session/update`. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ - readonly handleSessionUpdate: ( - handler: ( - notification: AcpSchema.SessionNotification, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `session/elicitation/complete`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete - */ - readonly handleElicitationComplete: ( - handler: ( - notification: AcpSchema.ElicitationCompleteNotification, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a fallback extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleUnknownExtRequest: ( - handler: (method: string, params: unknown) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a fallback extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleUnknownExtNotification: ( - handler: (method: string, params: unknown) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a typed extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleExtRequest: ( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a typed extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleExtNotification: ( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, - ) => Effect.Effect; -} - -export class AcpClient extends Context.Service()( - "effect-acp/client/AcpClient", -) {} + readonly handleUnknownExtNotification: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a typed extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtRequest: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a typed extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtNotification: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + } +>()("effect-acp/client/AcpClient") {} interface AcpCoreRequestHandlers { requestPermission?: ( @@ -310,7 +309,7 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( stdio: Stdio.Stdio, options: AcpClientOptions = {}, terminationError?: Effect.Effect, -): Effect.fn.Return { +): Effect.fn.Return { const coreHandlers: AcpCoreRequestHandlers = {}; const notificationHandlers: AcpNotificationHandlers = { sessionUpdate: { handlers: [], pending: [] }, @@ -463,19 +462,32 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( notify: transport.notify, }, agent: { - initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)), - authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)), - logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)), - createSession: (payload) => callRpc(rpc[AGENT_METHODS.session_new](payload)), - loadSession: (payload) => callRpc(rpc[AGENT_METHODS.session_load](payload)), - listSessions: (payload) => callRpc(rpc[AGENT_METHODS.session_list](payload)), - forkSession: (payload) => callRpc(rpc[AGENT_METHODS.session_fork](payload)), - resumeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_resume](payload)), - closeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_close](payload)), - setSessionModel: (payload) => callRpc(rpc[AGENT_METHODS.session_set_model](payload)), + initialize: (payload) => + callRpc(AGENT_METHODS.initialize, rpc[AGENT_METHODS.initialize](payload)), + authenticate: (payload) => + callRpc(AGENT_METHODS.authenticate, rpc[AGENT_METHODS.authenticate](payload)), + logout: (payload) => callRpc(AGENT_METHODS.logout, rpc[AGENT_METHODS.logout](payload)), + createSession: (payload) => + callRpc(AGENT_METHODS.session_new, rpc[AGENT_METHODS.session_new](payload)), + loadSession: (payload) => + callRpc(AGENT_METHODS.session_load, rpc[AGENT_METHODS.session_load](payload)), + listSessions: (payload) => + callRpc(AGENT_METHODS.session_list, rpc[AGENT_METHODS.session_list](payload)), + forkSession: (payload) => + callRpc(AGENT_METHODS.session_fork, rpc[AGENT_METHODS.session_fork](payload)), + resumeSession: (payload) => + callRpc(AGENT_METHODS.session_resume, rpc[AGENT_METHODS.session_resume](payload)), + closeSession: (payload) => + callRpc(AGENT_METHODS.session_close, rpc[AGENT_METHODS.session_close](payload)), + setSessionModel: (payload) => + callRpc(AGENT_METHODS.session_set_model, rpc[AGENT_METHODS.session_set_model](payload)), setSessionConfigOption: (payload) => - callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)), - prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)), + callRpc( + AGENT_METHODS.session_set_config_option, + rpc[AGENT_METHODS.session_set_config_option](payload), + ), + prompt: (payload) => + callRpc(AGENT_METHODS.session_prompt, rpc[AGENT_METHODS.session_prompt](payload)), cancel: (payload) => transport.notify(AGENT_METHODS.session_cancel, payload), }, handleRequestPermission: (handler) => @@ -559,6 +571,9 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }); }); +export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer => + Layer.effect(AcpClient, make(stdio, options)); + export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: AcpClientOptions = {}, diff --git a/packages/effect-acp/src/errors.test.ts b/packages/effect-acp/src/errors.test.ts new file mode 100644 index 00000000000..a54c5af4843 --- /dev/null +++ b/packages/effect-acp/src/errors.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; + +import * as AcpSchema from "./_generated/schema.gen.ts"; +import { callRpc, runHandler } from "./_internal/shared.ts"; +import * as AcpError from "./errors.ts"; + +const decodeNestedNumberPayload = Schema.decodeUnknownEffect( + Schema.Struct({ profile: Schema.Struct({ token: Schema.Number }) }), +); +const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +describe("effect-acp errors", () => { + it.effect("retains RPC method and cause without deriving the message from the cause", () => { + const rootCause = new Error("connection details that must not become the public message"); + const failure = new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: rootCause.message, + cause: rootCause, + }), + }); + + return Effect.gen(function* () { + const error = yield* callRpc("session/new", Effect.fail(failure)).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpTransportError", + operation: "call-rpc", + method: "session/new", + cause: failure, + }); + expect(error.message).toBe("ACP transport operation call-rpc failed for method session/new."); + expect(error.message).not.toContain(rootCause.message); + }); + }); + + it.effect("preserves protocol request errors as request errors", () => { + const failure = AcpSchema.Error.make({ + code: -32602, + message: "Invalid params", + data: { field: "sessionId" }, + }); + + return Effect.gen(function* () { + const error = yield* callRpc("session/load", Effect.fail(failure)).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Invalid params", + data: { field: "sessionId" }, + method: "session/load", + operation: "receive-response", + }); + }); + }); + + it("does not expose legacy diagnostic detail as the transport message", () => { + const cause = new Error("connection refused at a private endpoint"); + const error = new AcpError.AcpTransportError({ + detail: cause.message, + cause, + }); + + expect(error.message).toBe("ACP transport operation failed."); + expect(error.cause).toBe(cause); + }); + + it("preserves structured extension handler failures behind stable request errors", () => { + const cause = new AcpError.AcpTransportError({ + operation: "read-input-stream", + cause: new Error("private transport diagnostics"), + }); + const error = AcpError.AcpRequestError.fromExtensionHandlerError(cause, "x/test"); + + expect(error).toMatchObject({ + code: -32603, + method: "x/test", + operation: "handle-extension-request", + cause, + }); + expect(error.message).toBe("ACP extension request handler failed for method 'x/test'"); + expect(error.message).not.toContain(cause.message); + }); + + it.effect("uses the structured mapper for core handler failures", () => { + const cause = new AcpError.AcpTransportError({ + operation: "read-input-stream", + cause: new Error("private transport diagnostics"), + }); + + return Effect.gen(function* () { + const error = yield* runHandler(() => Effect.fail(cause), {}, "fs/read_text_file").pipe( + Effect.flip, + ); + + expect(error).toMatchObject({ + code: -32603, + message: "ACP request handler failed for method 'fs/read_text_file'", + }); + expect(error.message).not.toContain(cause.message); + }); + }); + + it.effect("keeps invalid extension payload values only in the exact schema cause", () => + Effect.gen(function* () { + const secret = "acp-schema-payload-secret"; + const cause = yield* decodeNestedNumberPayload({ profile: { token: secret } }).pipe( + Effect.flip, + ); + const error = AcpError.AcpRequestError.invalidExtensionPayload("x/private", cause); + const { cause: directCause, ...directDiagnostics } = error; + + expect(directCause).toBe(cause); + expect(error).toMatchObject({ + method: "x/private", + operation: "decode-extension-request-payload", + maximumPathDepth: 2, + }); + expect(error.issueCount).toBeGreaterThan(0); + expect(error.issueKinds).toContain("Pointer"); + expect(error.message).toBe("Invalid payload for ACP extension method 'x/private'."); + expect(error.message).not.toContain(secret); + expect(encodeUnknownJson(directDiagnostics)).not.toContain(secret); + expect(encodeUnknownJson(error.toProtocolError())).not.toContain(secret); + + const protocolError = AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + "x/private", + cause, + ); + const { cause: protocolCause, ...protocolDiagnostics } = protocolError; + expect(protocolCause).toBe(cause); + expect(protocolError).toMatchObject({ + method: "x/private", + operation: "decode-notification-payload", + maximumPathDepth: 2, + }); + expect(protocolError.message).not.toContain(secret); + expect(encodeUnknownJson(protocolDiagnostics)).not.toContain(secret); + expect("detail" in protocolError).toBe(false); + }), + ); +}); diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 05e0b1b43ec..f92568a1483 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -1,7 +1,81 @@ import * as Schema from "effect/Schema"; +import type * as SchemaIssue from "effect/SchemaIssue"; import * as AcpSchema from "./_generated/schema.gen.ts"; +export const AcpRequestOperation = Schema.Literals([ + "decode-extension-request-payload", + "encode-extension-response", + "handle-request", + "handle-extension-request", + "receive-response", + "receive-streaming-response", +]); +export type AcpRequestOperation = typeof AcpRequestOperation.Type; + +export const AcpSchemaIssueKind = Schema.Literals([ + "Filter", + "Encoding", + "Pointer", + "Composite", + "AnyOf", + "InvalidType", + "InvalidValue", + "MissingKey", + "UnexpectedKey", + "Forbidden", + "OneOf", +]); +export type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; + +export interface AcpSchemaIssueDiagnostics { + readonly issueCount: number; + readonly issueKinds: ReadonlyArray; + readonly maximumPathDepth: number; +} + +const schemaIssueDiagnostics = (root: SchemaIssue.Issue): AcpSchemaIssueDiagnostics => { + let issueCount = 0; + let maximumPathDepth = 0; + const issueKinds = new Set(); + + const visit = (issue: SchemaIssue.Issue, pathDepth: number): void => { + issueCount += 1; + issueKinds.add(issue._tag); + maximumPathDepth = Math.max(maximumPathDepth, pathDepth); + switch (issue._tag) { + case "Filter": + case "Encoding": + visit(issue.issue, pathDepth); + break; + case "Pointer": + visit(issue.issue, pathDepth + issue.path.length); + break; + case "Composite": + case "AnyOf": + for (const child of issue.issues) visit(child, pathDepth); + break; + } + }; + + visit(root, 0); + return { + issueCount, + issueKinds: [...issueKinds], + maximumPathDepth, + }; +}; + +export interface AcpRequestDiagnostics { + readonly method?: string; + readonly requestId?: string; + readonly operation?: AcpRequestOperation; + readonly cause?: unknown; + readonly issueCount?: number; + readonly issueKinds?: ReadonlyArray; + readonly maximumPathDepth?: number; +} + export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { command: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -17,6 +91,7 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass()( "AcpProtocolParseError", { - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: AcpProtocolParseOperation, + method: Schema.optionalKey(Schema.String), + requestId: Schema.optionalKey(Schema.String), + issueCount: Schema.optionalKey(Schema.Number), + issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)), + maximumPathDepth: Schema.optionalKey(Schema.Number), + cause: Schema.Defect(), }, ) { override get message() { - return `Failed to parse ACP protocol message: ${this.detail}`; + const method = this.method === undefined ? "" : ` for method '${this.method}'`; + return `ACP protocol operation '${this.operation}' failed${method}.`; + } + + static fromSchemaError( + operation: AcpProtocolParseOperation, + method: string, + cause: Schema.SchemaError, + ) { + return new AcpProtocolParseError({ + operation, + method, + ...schemaIssueDiagnostics(cause.issue), + cause, + }); + } + + static fromEncodingError( + method: string | undefined, + requestId: string | undefined, + cause: unknown, + ) { + return new AcpProtocolParseError({ + operation: "encode-message", + ...(method === undefined ? {} : { method }), + ...(requestId === undefined ? {} : { requestId }), + cause, + }); } } export class AcpTransportError extends Schema.TaggedErrorClass()( "AcpTransportError", { - detail: Schema.String, + operation: Schema.optional( + Schema.Literals(["call-rpc", "read-input-stream", "read-process-exit-status"]), + ), + method: Schema.optional(Schema.String), + detail: Schema.optional(Schema.String), + pid: Schema.optionalKey(Schema.Int), cause: Schema.Defect(), }, -) {} +) { + override get message() { + const method = this.method ? ` for method ${this.method}` : ""; + return this.operation + ? `ACP transport operation ${this.operation} failed${method}.` + : "ACP transport operation failed."; + } +} + +export class AcpInputStreamEndedError extends Schema.TaggedErrorClass()( + "AcpInputStreamEndedError", + {}, +) { + override get message() { + return "ACP input stream ended."; + } +} export class AcpRequestError extends Schema.TaggedErrorClass()("AcpRequestError", { code: AcpSchema.ErrorCode, errorMessage: Schema.String, data: Schema.optional(Schema.Unknown), + method: Schema.optionalKey(Schema.String), + requestId: Schema.optionalKey(Schema.String), + operation: Schema.optionalKey(AcpRequestOperation), + issueCount: Schema.optionalKey(Schema.Number), + issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)), + maximumPathDepth: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), }) { override get message() { return this.errorMessage; } - static fromProtocolError(error: AcpSchema.Error) { + static fromProtocolError( + error: AcpSchema.Error, + context: { + readonly method: string; + readonly requestId?: string; + readonly cause?: unknown; + }, + ) { return new AcpRequestError({ code: error.code, errorMessage: error.message, ...(error.data !== undefined ? { data: error.data } : {}), + method: context.method, + ...(context.requestId === undefined ? {} : { requestId: context.requestId }), + operation: "receive-response", + cause: context.cause ?? error, + }); + } + + static fromExtensionResponseFailure(method: string, requestId: string, cause: unknown) { + return AcpRequestError.internalError("Extension request failed", undefined, { + method, + requestId, + operation: "receive-response", + cause, + }); + } + + static fromExtensionResponseEncodingError( + method: string, + requestId: string, + cause: AcpProtocolParseError, + ) { + return AcpRequestError.internalError("Internal error", undefined, { + method, + requestId, + operation: "encode-extension-response", + cause, }); } + static unsupportedStreamingResponse(method: string, requestId: string) { + return AcpRequestError.internalError( + "Streaming extension responses are not supported", + undefined, + { + method, + requestId, + operation: "receive-streaming-response", + }, + ); + } + + static fromCoreHandlerError(error: AcpError, method: string) { + if (error._tag === "AcpRequestError") { + return error; + } + return AcpRequestError.internalError( + `ACP request handler failed for method '${method}'`, + undefined, + { + method, + operation: "handle-request", + cause: error, + }, + ); + } + + static fromExtensionHandlerError(error: AcpError, method: string) { + if (error._tag === "AcpRequestError") { + return error; + } + return AcpRequestError.internalError( + `ACP extension request handler failed for method '${method}'`, + undefined, + { + method, + operation: "handle-extension-request", + cause: error, + }, + ); + } + static parseError(message = "Parse error", data?: unknown) { return new AcpRequestError({ code: -32700, @@ -95,11 +312,29 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( }); } - static internalError(message = "Internal error", data?: unknown) { + static invalidExtensionPayload(method: string, cause: Schema.SchemaError) { + const diagnostics = schemaIssueDiagnostics(cause.issue); + return new AcpRequestError({ + code: -32602, + errorMessage: `Invalid payload for ACP extension method '${method}'.`, + data: diagnostics, + method, + operation: "decode-extension-request-payload", + ...diagnostics, + cause, + }); + } + + static internalError( + message = "Internal error", + data?: unknown, + diagnostics: AcpRequestDiagnostics = {}, + ) { return new AcpRequestError({ code: -32603, errorMessage: message, ...(data !== undefined ? { data } : {}), + ...diagnostics, }); } @@ -134,6 +369,7 @@ export const AcpError = Schema.Union([ AcpProcessExitedError, AcpProtocolParseError, AcpTransportError, + AcpInputStreamEndedError, ]); export type AcpError = typeof AcpError.Type; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 093d4acfcfa..ece068dfc88 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -48,6 +48,8 @@ const decodeExtRequest = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); const decodeRequestPermissionResponse = Schema.decodeEffect( Schema.fromJsonString(RequestPermissionResponse), ); +const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +const encoder = new TextEncoder(); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), ); @@ -132,6 +134,49 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("keeps invalid core notification values only in the schema cause", () => + Effect.gen(function* () { + const secret = "acp-core-notification-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: { secret }, + update: { + sessionUpdate: "plan", + entries: [], + }, + }, + })}\n`, + ), + ); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, AcpError.AcpProtocolParseError); + const parseError = error as AcpError.AcpProtocolParseError; + const { cause, ...directDiagnostics } = parseError; + assert.equal(parseError.operation, "decode-notification-payload"); + assert.equal(parseError.method, "session/update"); + assert.isAbove(parseError.issueCount ?? 0, 0); + assert.include(parseError.issueKinds ?? [], "Pointer"); + assert.isAbove(parseError.maximumPathDepth ?? 0, 0); + assert.isTrue(Schema.isSchemaError(cause)); + assert.notInclude(parseError.message, secret); + assert.notInclude(encodeUnknownJsonString(directDiagnostics), secret); + }), + ); + it.effect("logs outgoing notifications when logOutgoing is enabled", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); @@ -172,6 +217,38 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("logs decode failures without copying the cause or wire payload", () => + Effect.gen(function* () { + const secret = "acp-wire-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const events: Array = []; + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + logIncoming: true, + logger: (event) => + Effect.sync(() => { + events.push(event); + }), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer(input, encoder.encode(`{"secret":"${secret}"\n`)); + yield* Deferred.await(termination); + + const event = events.find(({ stage }) => stage === "decode_failed"); + assert.deepEqual(event, { + direction: "incoming", + stage: "decode_failed", + payload: { + operation: "decode-wire-message", + }, + }); + assert.notInclude(encodeUnknownJsonString(event), secret); + }), + ); + it.effect("fails notification encoding through the declared ACP error channel", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); @@ -182,13 +259,34 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); - assert.equal(bigintError.detail, "Failed to encode ACP message"); + assert.equal(bigintError.operation, "encode-message"); + assert.equal(bigintError.method, "x/test"); + assert.instanceOf(bigintError.cause, TypeError); + assert.equal( + bigintError.message, + "ACP protocol operation 'encode-message' failed for method 'x/test'.", + ); const circular: Record = {}; circular.self = circular; const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, AcpError.AcpProtocolParseError); - assert.equal(circularError.detail, "Failed to encode ACP message"); + assert.equal(circularError.operation, "encode-message"); + assert.equal(circularError.method, "x/test"); + assert.instanceOf(circularError.cause, TypeError); + + const requestError = yield* transport.request("x/request", 1n).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected request encoding to fail"), + }), + ); + assert.instanceOf(requestError, AcpError.AcpProtocolParseError); + assert.deepInclude(requestError, { + operation: "encode-message", + method: "x/request", + requestId: "1", + }); }), ); @@ -230,6 +328,60 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("correlates extension response errors with the originating request", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const response = yield* transport + .request("x/private", { hello: "world" }) + .pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + id: 1, + error: { + _tag: "Cause", + code: -32602, + message: "Invalid params", + data: [ + { + _tag: "Fail", + error: { + code: -32602, + message: "Invalid params", + data: { field: "hello" }, + }, + }, + ], + }, + })}\n`, + ), + ); + + const error = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected extension request to fail"), + }), + ); + assert.instanceOf(error, AcpError.AcpRequestError); + assert.deepInclude(error, { + code: -32602, + errorMessage: "Invalid params", + method: "x/private", + requestId: "1", + operation: "receive-response", + }); + }), + ); + it.effect("preserves zero-valued ids for inbound core client requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); @@ -381,14 +533,35 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; + readonly message: string; readonly cause: unknown; }; assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "ACP protocol terminated."); assert.instanceOf(defect.cause, AcpError.AcpProcessExitedError); assert.equal((defect.cause as AcpError.AcpProcessExitedError).code, 7); }), ); + it.effect("classifies an input stream ending without inventing a cause", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.end(input); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, AcpError.AcpInputStreamEndedError); + assert.equal(error.message, "ACP input stream ended."); + assert.equal("cause" in error, false); + }), + ); + it.effect("does not emit a second process-exit error after a decode failure", () => Effect.gen(function* () { const handle = yield* makeHandle({ @@ -413,9 +586,40 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; + readonly message: string; + readonly cause: unknown; + }; + assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "ACP protocol terminated."); + assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); + }), + ); + + it.effect("keeps client send failure messages independent from the cause", () => + Effect.gen(function* () { + const { stdio } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const failure = yield* transport.clientProtocol + .send(0, { + _tag: "Request", + id: "request-1", + tag: "x/test", + payload: 1n, + headers: [], + }) + .pipe(Effect.flip); + const defect = failure.reason as { + readonly _tag: string; + readonly message: string; readonly cause: unknown; }; + assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "Failed to send ACP protocol message."); assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); }), ); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 56a7ce81ab8..27c619296c0 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -17,7 +17,6 @@ import * as AcpSchema from "./_generated/schema.gen.ts"; import { CLIENT_METHODS } from "./_generated/meta.gen.ts"; import * as AcpError from "./errors.ts"; const isAcpError = Schema.is(AcpError.AcpError); -const isAcpRequestError = Schema.is(AcpError.AcpRequestError); export interface AcpProtocolLogEvent { readonly direction: "incoming" | "outgoing"; @@ -67,6 +66,11 @@ export interface AcpPatchedProtocol { readonly notify: (method: string, payload: unknown) => Effect.Effect; } +interface AcpPendingRequest { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); const decodeElicitationComplete = Schema.decodeUnknownEffect( AcpSchema.ElicitationCompleteNotification, @@ -84,9 +88,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const outgoing = yield* Queue.unbounded>(); const nextRequestId = yield* Ref.make(1n); const terminationHandled = yield* Ref.make(false); - const extPending = yield* Ref.make( - new Map>(), - ); + const extPending = yield* Ref.make(new Map()); const logProtocol = (event: AcpProtocolLogEvent) => { if (event.direction === "incoming" && !options.logIncoming) { @@ -110,13 +112,17 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi payload: message, }); + const method = message._tag === "Request" ? message.tag : undefined; + const encodedRequestId = + message._tag === "Request" + ? message.id + : "requestId" in message + ? message.requestId + : undefined; + const requestId = encodedRequestId === "" ? undefined : encodedRequestId; const encoded = yield* Effect.try({ try: () => parser.encode(message), - catch: (cause) => - new AcpError.AcpProtocolParseError({ - detail: "Failed to encode ACP message", - cause, - }), + catch: (cause) => AcpError.AcpProtocolParseError.fromEncodingError(method, requestId, cause), }); if (encoded) { @@ -132,16 +138,16 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const resolveExtPending = ( requestId: string, - onFound: (deferred: Deferred.Deferred) => Effect.Effect, + onFound: (pendingRequest: AcpPendingRequest) => Effect.Effect, ) => Ref.modify(extPending, (pending) => { - const deferred = pending.get(requestId); - if (!deferred) { + const pendingRequest = pending.get(requestId); + if (!pendingRequest) { return [Effect.void, pending] as const; } const next = new Map(pending); next.delete(requestId); - return [onFound(deferred), next] as const; + return [onFound(pendingRequest), next] as const; }).pipe(Effect.flatten); const removeExtPending = (requestId: string) => @@ -155,15 +161,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }); const completeExtPendingFailure = (requestId: string, error: AcpError.AcpError) => - resolveExtPending(requestId, (deferred) => Deferred.fail(deferred, error)); + resolveExtPending(requestId, ({ deferred }) => Deferred.fail(deferred, error)); const completeExtPendingSuccess = (requestId: string, value: unknown) => - resolveExtPending(requestId, (deferred) => Deferred.succeed(deferred, value)); + resolveExtPending(requestId, ({ deferred }) => Deferred.succeed(deferred, value)); const failAllExtPending = (error: AcpError.AcpError) => Ref.getAndSet(extPending, new Map()).pipe( Effect.flatMap((pending) => - Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), { + Effect.forEach([...pending.values()], ({ deferred }) => Deferred.fail(deferred, error), { discard: true, }), ), @@ -184,7 +190,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi _tag: "ClientProtocolError", error: new RpcClientError.RpcClientError({ reason: new RpcClientError.RpcClientDefect({ - message: error.message, + message: "ACP protocol terminated.", cause: error, }), }), @@ -243,7 +249,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi } return options.onExtRequest(message.tag, message.payload).pipe( Effect.matchEffect({ - onFailure: (error) => respondWithError(message.id, normalizeToRequestError(error)), + onFailure: (error) => + respondWithError( + message.id, + AcpError.AcpRequestError.fromExtensionHandlerError(error, message.tag), + ), onSuccess: (value) => respondWithSuccess(message.id, value), }), ); @@ -261,12 +271,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi params, }) satisfies AcpIncomingNotification, ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${CLIENT_METHODS.session_update} notification payload`, - cause, - }), + Effect.mapError((cause) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + CLIENT_METHODS.session_update, + cause, + ), ), Effect.flatMap(dispatchNotification), ); @@ -281,12 +291,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi params, }) satisfies AcpIncomingNotification, ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${CLIENT_METHODS.session_elicitation_complete} notification payload`, - cause, - }), + Effect.mapError((cause) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + CLIENT_METHODS.session_elicitation_complete, + cause, + ), ), Effect.flatMap(dispatchNotification), ); @@ -300,7 +310,26 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi if (!options.serverRequestMethods.has(message.tag)) { return handleExtRequest(message).pipe( - Effect.catch(() => respondWithError(message.id, AcpError.AcpRequestError.internalError())), + Effect.catchTags({ + AcpProtocolParseError: (error) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + method: message.tag, + requestId: message.id, + operation: error.operation, + }), + Effect.andThen( + respondWithError( + message.id, + AcpError.AcpRequestError.fromExtensionResponseEncodingError( + message.tag, + message.id, + error, + ), + ), + ), + ), + }), Effect.asVoid, ); } @@ -311,7 +340,8 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => Ref.get(extPending).pipe( Effect.flatMap((pending) => { - if (!pending.has(message.requestId)) { + const pendingRequest = pending.get(message.requestId); + if (!pendingRequest) { return Queue.offer(clientQueue, message).pipe(Effect.asVoid); } if (message.exit._tag === "Success") { @@ -321,12 +351,20 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi if (failure && isProtocolError(failure.error)) { return completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.fromProtocolError(failure.error), + AcpError.AcpRequestError.fromProtocolError(failure.error, { + method: pendingRequest.method, + requestId: message.requestId, + cause: message.exit.cause, + }), ); } return completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.internalError("Extension request failed"), + AcpError.AcpRequestError.fromExtensionResponseFailure( + pendingRequest.method, + message.requestId, + message.exit.cause, + ), ); }), ); @@ -341,16 +379,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi return handleExitEncoded(message); case "Chunk": return Ref.get(extPending).pipe( - Effect.flatMap((pending) => - pending.has(message.requestId) + Effect.flatMap((pending) => { + const pendingRequest = pending.get(message.requestId); + return pendingRequest ? completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.internalError( - "Streaming extension responses are not supported", + AcpError.AcpRequestError.unsupportedStreamingResponse( + pendingRequest.method, + message.requestId, ), ) - : Queue.offer(clientQueue, message).pipe(Effect.asVoid), - ), + : Queue.offer(clientQueue, message).pipe(Effect.asVoid); + }), ); case "Defect": case "ClientProtocolError": @@ -379,7 +419,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi >, catch: (cause) => new AcpError.AcpProtocolParseError({ - detail: "Failed to decode ACP wire message", + operation: "decode-wire-message", cause, }), }), @@ -396,8 +436,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi direction: "incoming", stage: "decode_failed", payload: { - detail: error.detail, - cause: error.cause, + operation: error.operation, + ...(error.method === undefined ? {} : { method: error.method }), + ...(error.requestId === undefined ? {} : { requestId: error.requestId }), + ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), + ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), + ...(error.maximumPathDepth === undefined + ? {} + : { maximumPathDepth: error.maximumPathDepth }), }, }), ), @@ -413,7 +459,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const normalized: AcpError.AcpError = isAcpError(error) ? error : new AcpError.AcpTransportError({ - detail: error instanceof Error ? error.message : String(error), + operation: "read-input-stream", cause: error, }); return handleTermination(() => Effect.succeed(normalized)); @@ -421,13 +467,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi onSuccess: () => handleTermination( () => - options.terminationError ?? - Effect.succeed( - new AcpError.AcpTransportError({ - detail: "ACP input stream ended", - cause: new Error("ACP input stream ended"), - }), - ), + options.terminationError ?? Effect.succeed(new AcpError.AcpInputStreamEndedError({})), ), }), Effect.forkScoped, @@ -441,7 +481,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Stream.runForEach((message) => f(message)), Effect.forever, ), - send: (_clientId, request) => offerOutgoing(request).pipe(Effect.mapError(toRpcClientError)), + send: (_clientId, request) => + offerOutgoing(request).pipe( + Effect.mapError( + (error) => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "Failed to send ACP protocol message.", + cause: error, + }), + }), + ), + ), supportsAck: true, supportsTransferables: false, }); @@ -481,18 +532,16 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi (current) => [current, current + 1n] as const, ); const deferred = yield* Deferred.make(); - yield* Ref.update(extPending, (pending) => new Map(pending).set(String(requestId), deferred)); + yield* Ref.update(extPending, (pending) => + new Map(pending).set(String(requestId), { deferred, method }), + ); yield* offerOutgoing({ _tag: "Request", id: String(requestId), tag: method, payload, headers: [], - }).pipe( - Effect.catch((error) => - removeExtPending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), - ), - ); + }).pipe(Effect.tapError(() => removeExtPending(String(requestId)))); return yield* Deferred.await(deferred).pipe( Effect.onInterrupt(() => removeExtPending(String(requestId))), ); @@ -521,16 +570,3 @@ function isProtocolError( typeof value.message === "string" ); } - -function normalizeToRequestError(error: AcpError.AcpError): AcpError.AcpRequestError { - return isAcpRequestError(error) ? error : AcpError.AcpRequestError.internalError(error.message); -} - -function toRpcClientError(error: AcpError.AcpError): RpcClientError.RpcClientError { - return new RpcClientError.RpcClientError({ - reason: new RpcClientError.RpcClientDefect({ - message: error.message, - cause: error, - }), - }); -} diff --git a/packages/effect-acp/src/terminal.ts b/packages/effect-acp/src/terminal.ts index 088ff863738..b892f040436 100644 --- a/packages/effect-acp/src/terminal.ts +++ b/packages/effect-acp/src/terminal.ts @@ -23,23 +23,3 @@ export interface AcpTerminal { */ readonly release: Effect.Effect; } - -export interface MakeTerminalOptions { - readonly sessionId: string; - readonly terminalId: string; - readonly output: Effect.Effect; - readonly waitForExit: Effect.Effect; - readonly kill: Effect.Effect; - readonly release: Effect.Effect; -} - -export function makeTerminal(options: MakeTerminalOptions): AcpTerminal { - return { - sessionId: options.sessionId, - terminalId: options.terminalId, - output: options.output, - waitForExit: options.waitForExit, - kill: options.kill, - release: options.release, - }; -} diff --git a/packages/effect-codex-app-server/src/_internal/shared.test.ts b/packages/effect-codex-app-server/src/_internal/shared.test.ts new file mode 100644 index 00000000000..62b1373f12c --- /dev/null +++ b/packages/effect-codex-app-server/src/_internal/shared.test.ts @@ -0,0 +1,151 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as CodexError from "../errors.ts"; +import * as Shared from "./shared.ts"; + +const decodeNestedNumberPayload = Schema.decodeUnknownEffect( + Schema.Struct({ profile: Schema.Struct({ token: Schema.Number }) }), +); +const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +it.effect("preserves schema decode diagnostics without deriving the message from the cause", () => + Effect.gen(function* () { + const error = yield* Shared.decodeOptionalPayload("thread/start", Schema.String, 42).pipe( + Effect.flip, + ); + + assert.instanceOf(error, CodexError.CodexAppServerRequestError); + assert.equal(error.code, -32602); + assert.equal(error.method, "thread/start"); + assert.equal(error.operation, "decode-payload"); + assert.equal( + error.message, + "Invalid payload for method 'thread/start' during 'decode-payload'", + ); + assert.isTrue(Schema.isSchemaError(error.cause)); + + const protocolError = error.toProtocolError(); + assert.equal(protocolError.code, -32602); + assert.equal(protocolError.message, error.message); + assert.property(protocolError, "data"); + assert.notProperty(protocolError, "method"); + assert.notProperty(protocolError, "operation"); + assert.notProperty(protocolError, "cause"); + }), +); + +it.effect("preserves schema encode diagnostics", () => + Effect.gen(function* () { + const error = yield* Shared.encodeOptionalPayload( + "thread/start", + Schema.Number, + "not-a-number" as never, + ).pipe(Effect.flip); + + assert.equal(error.method, "thread/start"); + assert.equal(error.operation, "encode-payload"); + assert.equal( + error.message, + "Invalid payload for method 'thread/start' during 'encode-payload'", + ); + assert.isTrue(Schema.isSchemaError(error.cause)); + }), +); + +it.effect("does not invent a cause when a method has no payload schema", () => + Effect.gen(function* () { + const secret = "unexpected-payload-secret"; + const error = yield* Shared.decodeOptionalPayload("initialized", undefined, { + token: secret, + }).pipe(Effect.flip); + + assert.equal(error.method, "initialized"); + assert.equal(error.operation, "decode-payload"); + assert.equal(error.payloadKind, "object"); + assert.deepEqual(error.data, { payloadKind: "object" }); + assert.isUndefined(error.cause); + assert.notInclude(error.message, secret); + assert.notInclude(encodeUnknownJson(error.toProtocolError()), secret); + }), +); + +it.effect("keeps invalid payload values only in the exact schema cause", () => + Effect.gen(function* () { + const secret = "codex-schema-payload-secret"; + const cause = yield* decodeNestedNumberPayload({ profile: { token: secret } }).pipe( + Effect.flip, + ); + const error = CodexError.CodexAppServerRequestError.invalidPayload( + "thread/start", + "decode-payload", + cause, + ); + const { cause: directCause, ...directDiagnostics } = error; + + assert.strictEqual(directCause, cause); + assert.equal(error.method, "thread/start"); + assert.equal(error.operation, "decode-payload"); + assert.equal(error.maximumPathDepth, 2); + assert.isAbove(error.issueCount ?? 0, 0); + assert.include(error.issueKinds ?? [], "Pointer"); + assert.notInclude(error.message, secret); + assert.notInclude(encodeUnknownJson(directDiagnostics), secret); + assert.notInclude(encodeUnknownJson(error.toProtocolError()), secret); + }), +); + +it.effect("retains the request-handler error as the internal error cause", () => + Effect.gen(function* () { + const rootCause = new Error("socket closed"); + const source = new CodexError.CodexAppServerTransportError({ + operation: "read-input-stream", + cause: rootCause, + }); + const error = yield* Shared.runHandler( + (_payload: void) => Effect.fail(source), + undefined, + "thread/start", + ).pipe(Effect.flip); + + assert.equal(error.code, -32603); + assert.equal(error.method, "thread/start"); + assert.equal(error.operation, "handle-request"); + assert.equal( + error.message, + "Codex App Server request handler failed for method 'thread/start'", + ); + assert.strictEqual(error.cause, source); + assert.strictEqual(source.cause, rootCause); + assert.notInclude(error.message, source.message); + }), +); + +it.effect("passes request errors through without adding a wrapper", () => + Effect.gen(function* () { + const source = CodexError.CodexAppServerRequestError.invalidParams("Invalid thread id"); + const error = yield* Shared.runHandler( + (_payload: void) => Effect.fail(source), + undefined, + "thread/start", + ).pipe(Effect.flip); + + assert.strictEqual(error, source); + }), +); + +it.effect("retains the full notification payload decode cause chain", () => + Effect.gen(function* () { + const error = yield* Shared.decodeNotificationPayload( + "item/agentMessage/delta", + Schema.String, + 42, + ).pipe(Effect.flip); + + assert.equal(error.method, "item/agentMessage/delta"); + assert.equal(error.operation, "decode-notification-payload"); + assert.instanceOf(error.cause, CodexError.CodexAppServerRequestError); + assert.isTrue(Schema.isSchemaError(error.cause.cause)); + }), +); diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 99fe8e5360b..34155348abf 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -1,11 +1,8 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import * as CodexError from "../errors.ts"; -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); - export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); export const JsonRpcError = Schema.Struct({ @@ -30,16 +27,13 @@ export const decodeOptionalPayload = ( return Effect.sync(() => undefined as A); } return Effect.fail( - CodexError.CodexAppServerRequestError.invalidParams(`${method} does not accept params`, raw), + CodexError.CodexAppServerRequestError.unexpectedPayload(method, "decode-payload", raw), ); } return Schema.decodeUnknownEffect(schema)(raw).pipe( Effect.mapError((error) => - CodexError.CodexAppServerRequestError.invalidParams( - `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, - { issue: error.issue }, - ), + CodexError.CodexAppServerRequestError.invalidPayload(method, "decode-payload", error), ), ); }; @@ -54,19 +48,13 @@ export const encodeOptionalPayload = ( return Effect.sync(() => undefined); } return Effect.fail( - CodexError.CodexAppServerRequestError.invalidParams( - `${method} does not accept params`, - payload, - ), + CodexError.CodexAppServerRequestError.unexpectedPayload(method, "encode-payload", payload), ); } return Schema.encodeEffect(schema)(payload).pipe( Effect.mapError((error) => - CodexError.CodexAppServerRequestError.invalidParams( - `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, - { issue: error.issue }, - ), + CodexError.CodexAppServerRequestError.invalidPayload(method, "encode-payload", error), ), ); }; @@ -77,12 +65,12 @@ export const decodeNotificationPayload = ( raw: unknown, ): Effect.Effect => decodeOptionalPayload(method, schema, raw).pipe( - Effect.mapError( - (error) => - new CodexError.CodexAppServerProtocolParseError({ - detail: error.message, - cause: error, - }), + Effect.mapError((error) => + CodexError.CodexAppServerProtocolParseError.fromRequestError( + "decode-notification-payload", + method, + error, + ), ), ); @@ -96,6 +84,8 @@ export const runHandler = Effect.fnUntraced(function* ( } return yield* handler(payload).pipe( - Effect.mapError((error) => CodexError.normalizeToRequestError(error)), + Effect.mapError((error) => + CodexError.CodexAppServerRequestError.fromAppServerError(error, method), + ), ); }); diff --git a/packages/effect-codex-app-server/src/_internal/stdio.test.ts b/packages/effect-codex-app-server/src/_internal/stdio.test.ts new file mode 100644 index 00000000000..6dbf4c857eb --- /dev/null +++ b/packages/effect-codex-app-server/src/_internal/stdio.test.ts @@ -0,0 +1,48 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as CodexError from "../errors.ts"; +import { makeTerminationError } from "./stdio.ts"; + +describe("Codex App Server child process termination", () => { + it.effect("retains the process identifier with the exit code", () => + Effect.gen(function* () { + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(51), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9)), + }); + + assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); + assert.equal(error.pid, 51); + assert.equal(error.code, 9); + assert.equal(error.message, "Codex App Server process exited with code 9"); + }), + ); + + it.effect("retains the process identifier and exact exit-status cause", () => + Effect.gen(function* () { + const rootCause = new Error("private process diagnostics"); + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + cause: rootCause, + }); + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(52), + exitCode: Effect.fail(cause), + }); + + assert.instanceOf(error, CodexError.CodexAppServerTransportError); + assert.equal(error.pid, 52); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Codex App Server transport operation 'read-process-exit-status' failed.", + ); + assert.notInclude(error.message, rootCause.message); + }), + ); +}); diff --git a/packages/effect-codex-app-server/src/_internal/stdio.ts b/packages/effect-codex-app-server/src/_internal/stdio.ts index ced07fb53e2..312022824cb 100644 --- a/packages/effect-codex-app-server/src/_internal/stdio.ts +++ b/packages/effect-codex-app-server/src/_internal/stdio.ts @@ -44,14 +44,20 @@ export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () { }; }); +type ChildProcessTerminationHandle = Pick< + ChildProcessSpawner.ChildProcessHandle, + "exitCode" | "pid" +>; + export const makeTerminationError = ( - handle: ChildProcessSpawner.ChildProcessHandle, + handle: ChildProcessTerminationHandle, ): Effect.Effect => Effect.match(handle.exitCode, { onFailure: (cause) => new CodexError.CodexAppServerTransportError({ - detail: "Failed to determine Codex App Server process exit status", + operation: "read-process-exit-status", + pid: handle.pid, cause, }), - onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }), + onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code, pid: handle.pid }), }); diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index f031b48d19c..c0cb5b1dc23 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -5,7 +5,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as CodexRpc from "./_generated/meta.gen.ts"; import * as CodexError from "./errors.ts"; @@ -35,45 +35,46 @@ interface CodexAppServerClientRaw { readonly respondError: CodexProtocol.CodexAppServerPatchedProtocol["respondError"]; } -export interface CodexAppServerClientShape { - readonly raw: CodexAppServerClientRaw; - readonly request: ( - method: M, - payload: CodexRpc.ClientRequestParamsByMethod[M], - ) => Effect.Effect; - readonly notify: ( - method: M, - payload: CodexRpc.ClientNotificationParamsByMethod[M], - ) => Effect.Effect; - readonly handleServerRequest: ( - method: M, - handler: ( - payload: CodexRpc.ServerRequestParamsByMethod[M], - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleServerNotification: ( - method: M, - handler: ( - payload: CodexRpc.ServerNotificationParamsByMethod[M], - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleUnknownServerRequest: ( - handler: ( - method: string, - params: unknown, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleUnknownServerNotification: ( - handler: ( - method: string, - params: unknown, - ) => Effect.Effect, - ) => Effect.Effect; -} - export class CodexAppServerClient extends Context.Service< CodexAppServerClient, - CodexAppServerClientShape + { + readonly raw: CodexAppServerClientRaw; + readonly request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => Effect.Effect; + readonly notify: ( + method: M, + payload: CodexRpc.ClientNotificationParamsByMethod[M], + ) => Effect.Effect; + readonly handleServerRequest: ( + method: M, + handler: ( + payload: CodexRpc.ServerRequestParamsByMethod[M], + ) => Effect.Effect< + CodexRpc.ServerRequestResponsesByMethod[M], + CodexError.CodexAppServerError + >, + ) => Effect.Effect; + readonly handleServerNotification: ( + method: M, + handler: ( + payload: CodexRpc.ServerNotificationParamsByMethod[M], + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownServerRequest: ( + handler: ( + method: string, + params: unknown, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownServerNotification: ( + handler: ( + method: string, + params: unknown, + ) => Effect.Effect, + ) => Effect.Effect; + } >()("effect-codex-app-server/client/CodexAppServerClient") {} type ServerRequestHandler = ( @@ -87,7 +88,7 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make stdio: Stdio.Stdio, options: CodexAppServerClientOptions = {}, terminationError?: Effect.Effect, -): Effect.fn.Return { +): Effect.fn.Return { const requestHandlers = new Map(); const notificationHandlers = new Map>(); let unknownRequestHandler: @@ -249,6 +250,11 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make }); }); +export const layer = ( + stdio: Stdio.Stdio, + options: CodexAppServerClientOptions = {}, +): Layer.Layer => Layer.effect(CodexAppServerClient, make(stdio, options)); + export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions = {}, diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index d9977c63597..f0e0945d352 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -1,4 +1,126 @@ import * as Schema from "effect/Schema"; +import type * as SchemaIssue from "effect/SchemaIssue"; + +export const CodexAppServerRequestOperation = Schema.Literals([ + "decode-payload", + "encode-payload", + "handle-request", + "receive-response", +]); +export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; + +export const CodexAppServerSchemaIssueKind = Schema.Literals([ + "Filter", + "Encoding", + "Pointer", + "Composite", + "AnyOf", + "InvalidType", + "InvalidValue", + "MissingKey", + "UnexpectedKey", + "Forbidden", + "OneOf", +]); +export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; + +export interface CodexAppServerSchemaIssueDiagnostics { + readonly issueCount: number; + readonly issueKinds: ReadonlyArray; + readonly maximumPathDepth: number; +} + +const schemaIssueDiagnostics = (root: SchemaIssue.Issue): CodexAppServerSchemaIssueDiagnostics => { + let issueCount = 0; + let maximumPathDepth = 0; + const issueKinds = new Set(); + + const visit = (issue: SchemaIssue.Issue, pathDepth: number): void => { + issueCount += 1; + issueKinds.add(issue._tag); + maximumPathDepth = Math.max(maximumPathDepth, pathDepth); + switch (issue._tag) { + case "Filter": + case "Encoding": + visit(issue.issue, pathDepth); + break; + case "Pointer": + visit(issue.issue, pathDepth + issue.path.length); + break; + case "Composite": + case "AnyOf": + for (const child of issue.issues) visit(child, pathDepth); + break; + } + }; + + visit(root, 0); + return { + issueCount, + issueKinds: [...issueKinds], + maximumPathDepth, + }; +}; + +export const CodexAppServerPayloadKind = Schema.Literals([ + "null", + "array", + "string", + "number", + "boolean", + "bigint", + "object", + "symbol", + "function", + "undefined", +]); +export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; + +const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { + if (payload === null) return "null"; + if (Array.isArray(payload)) return "array"; + return typeof payload; +}; + +const protocolMessageFields = ["id", "method", "params", "result", "error"] as const; + +export const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); +export type CodexAppServerProtocolMessageField = typeof CodexAppServerProtocolMessageField.Type; + +export interface CodexAppServerRequestDiagnostics { + readonly method?: string; + readonly requestId?: string; + readonly operation?: CodexAppServerRequestOperation; + readonly cause?: unknown; + readonly issueCount?: number; + readonly issueKinds?: ReadonlyArray; + readonly maximumPathDepth?: number; + readonly payloadKind?: CodexAppServerPayloadKind; +} + +export const CodexAppServerProtocolParseOperation = Schema.Literals([ + "encode-wire-message", + "decode-wire-message", + "route-wire-message", + "decode-notification-payload", + "decode-request-payload", + "decode-response-payload", +]); +export type CodexAppServerProtocolParseOperation = typeof CodexAppServerProtocolParseOperation.Type; + +export const CodexAppServerTransportOperation = Schema.Literals([ + "read-input-stream", + "read-process-exit-status", +]); +export type CodexAppServerTransportOperation = typeof CodexAppServerTransportOperation.Type; + +export const CodexAppServerIdentifierPurpose = Schema.Literals([ + "provider-event", + "command-approval-request", + "file-change-approval-request", + "user-input-request", +]); +export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type; export interface CodexAppServerProtocolErrorShape { readonly code: number; @@ -24,6 +146,7 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass()( "CodexAppServerProtocolParseError", { - detail: Schema.String, + operation: CodexAppServerProtocolParseOperation, + method: Schema.optionalKey(Schema.String), + requestId: Schema.optionalKey(Schema.String), + payloadKind: Schema.optionalKey(CodexAppServerPayloadKind), + presentFields: Schema.optionalKey(Schema.Array(CodexAppServerProtocolMessageField)), + issueCount: Schema.optionalKey(Schema.Number), + issueKinds: Schema.optionalKey(Schema.Array(CodexAppServerSchemaIssueKind)), + maximumPathDepth: Schema.optionalKey(Schema.Number), cause: Schema.optional(Schema.Defect()), }, ) { override get message() { - return `Failed to parse Codex App Server protocol message: ${this.detail}`; + const method = this.method === undefined ? "" : ` for method '${this.method}'`; + return `Codex App Server protocol operation '${this.operation}' failed${method}.`; + } + + static fromSchemaError( + operation: CodexAppServerProtocolParseOperation, + cause: Schema.SchemaError, + context: { readonly method?: string; readonly requestId?: string } = {}, + ) { + return new CodexAppServerProtocolParseError({ + operation, + ...context, + ...schemaIssueDiagnostics(cause.issue), + cause, + }); + } + + static fromRequestError( + operation: CodexAppServerProtocolParseOperation, + method: string, + cause: CodexAppServerRequestError, + ) { + return new CodexAppServerProtocolParseError({ + operation, + method, + ...(cause.issueCount === undefined ? {} : { issueCount: cause.issueCount }), + ...(cause.issueKinds === undefined ? {} : { issueKinds: cause.issueKinds }), + ...(cause.maximumPathDepth === undefined ? {} : { maximumPathDepth: cause.maximumPathDepth }), + cause, + }); + } + + static fromUnroutableMessage(message: unknown) { + const diagnostics = { payloadKind: payloadKind(message) }; + if (typeof message !== "object" || message === null || Array.isArray(message)) { + return new CodexAppServerProtocolParseError({ + operation: "route-wire-message", + ...diagnostics, + }); + } + + const presentFields = protocolMessageFields.filter((field) => field in message); + const method = + "method" in message && typeof message.method === "string" ? message.method : undefined; + const requestId = + "id" in message && (typeof message.id === "string" || typeof message.id === "number") + ? String(message.id) + : undefined; + return new CodexAppServerProtocolParseError({ + operation: "route-wire-message", + ...diagnostics, + presentFields, + ...(method === undefined ? {} : { method }), + ...(requestId === undefined ? {} : { requestId }), + }); } } export class CodexAppServerTransportError extends Schema.TaggedErrorClass()( "CodexAppServerTransportError", { - detail: Schema.String, + operation: CodexAppServerTransportOperation, + pid: Schema.optionalKey(Schema.Int), cause: Schema.Defect(), }, ) { override get message() { - return this.detail; + return `Codex App Server transport operation '${this.operation}' failed.`; + } +} + +export class CodexAppServerIdentifierGenerationError extends Schema.TaggedErrorClass()( + "CodexAppServerIdentifierGenerationError", + { + purpose: CodexAppServerIdentifierPurpose, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to generate Codex App Server identifier for ${this.purpose}.`; + } +} + +export class CodexAppServerInputStreamEndedError extends Schema.TaggedErrorClass()( + "CodexAppServerInputStreamEndedError", + {}, +) { + override get message() { + return "Codex App Server input stream ended."; } } @@ -64,20 +270,51 @@ export class CodexAppServerRequestError extends Schema.TaggedErrorClass { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, CodexError.CodexAppServerProtocolParseError); - assert.equal(bigintError.detail, "Failed to encode Codex App Server message"); + assert.equal(bigintError.operation, "encode-wire-message"); + assert.equal(bigintError.method, "x/test"); + assert.exists(bigintError.cause); + assert.equal( + bigintError.message, + "Codex App Server protocol operation 'encode-wire-message' failed for method 'x/test'.", + ); const circular: Record = {}; circular.self = circular; const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, CodexError.CodexAppServerProtocolParseError); - assert.equal(circularError.detail, "Failed to encode Codex App Server message"); + assert.equal(circularError.operation, "encode-wire-message"); + assert.equal(circularError.method, "x/test"); + assert.exists(circularError.cause); + + const requestError = yield* transport.request("x/request", 1n).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected request encoding to fail"), + }), + ); + assert.instanceOf(requestError, CodexError.CodexAppServerProtocolParseError); + assert.deepInclude(requestError, { + operation: "encode-wire-message", + method: "x/request", + requestId: "1", + }); + }), + ); + + it.effect("correlates response errors with the originating request", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ stdio }); + + const response = yield* transport.request("thread/start", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encodeJsonl({ + id: 1, + error: { + code: -32602, + message: "Invalid params", + data: { field: "cwd" }, + }, + }), + ); + + const error = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected Codex App Server request to fail"), + }), + ); + assert.instanceOf(error, CodexError.CodexAppServerRequestError); + assert.deepInclude(error, { + code: -32602, + errorMessage: "Invalid params", + method: "thread/start", + requestId: "1", + operation: "receive-response", + }); + }), + ); + + it.effect("logs decode failures without copying the cause or wire payload", () => + Effect.gen(function* () { + const secret = "codex-wire-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const events: Array = []; + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + logIncoming: true, + logger: (event) => + Effect.sync(() => { + events.push(event); + }), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer(input, encoder.encode(`{"secret":"${secret}"\n`)); + yield* Deferred.await(termination); + + const event = events.find(({ stage }) => stage === "decode_failed"); + assert.exists(event); + assert.equal(event.direction, "incoming"); + const payload = event.payload as Record; + assert.equal(payload.operation, "decode-wire-message"); + assert.isNumber(payload.issueCount); + assert.isArray(payload.issueKinds); + assert.isNumber(payload.maximumPathDepth); + assert.equal("cause" in payload, false); + assert.equal("detail" in payload, false); + assert.notInclude(encodeUnknownJsonString(event), secret); + }), + ); + + it.effect("describes unroutable messages with safe structural diagnostics", () => + Effect.gen(function* () { + const secret = "codex-unroutable-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: true, method: "thread/start", params: { token: secret } }), + ); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, CodexError.CodexAppServerProtocolParseError); + assert.deepInclude(error, { + operation: "route-wire-message", + method: "thread/start", + payloadKind: "object", + presentFields: ["id", "method", "params"], + }); + assert.isUndefined(error.requestId); + assert.notProperty(error, "detail"); + assert.notProperty(error, "cause"); + assert.notInclude(error.message, secret); + }), + ); + + it.effect("classifies an input stream ending without inventing a cause", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.end(input); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, CodexError.CodexAppServerInputStreamEndedError); + assert.equal(error.message, "Codex App Server input stream ended."); + assert.equal("cause" in error, false); }), ); }); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index 0fc2ce73c5c..825c59b9b2c 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -67,6 +67,11 @@ export interface CodexAppServerPatchedProtocol { ) => Effect.Effect; } +interface CodexAppServerPendingRequest { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -94,33 +99,40 @@ const encodeWireMessage = ( ): Effect.Effect => encodeJsonString(message).pipe( Effect.map((encoded) => `${encoded}\n`), - Effect.mapError( - (cause) => - new CodexError.CodexAppServerProtocolParseError({ - detail: "Failed to encode Codex App Server message", - cause, - }), - ), + Effect.mapError((cause) => { + const method = typeof message.method === "string" ? message.method : undefined; + const requestId = + typeof message.id === "string" || typeof message.id === "number" + ? String(message.id) + : undefined; + return CodexError.CodexAppServerProtocolParseError.fromSchemaError( + "encode-wire-message", + cause, + { + ...(method === undefined ? {} : { method }), + ...(requestId === undefined ? {} : { requestId }), + }, + ); + }), ); const decodeWireMessage = ( line: string, ): Effect.Effect => decodeJsonString(line).pipe( - Effect.mapError( - (cause) => - new CodexError.CodexAppServerProtocolParseError({ - detail: "Failed to decode Codex App Server wire message", - cause, - }), + Effect.mapError((cause) => + CodexError.CodexAppServerProtocolParseError.fromSchemaError("decode-wire-message", cause), ), ); -const normalizeIncomingError = (error: unknown, detail: string): CodexError.CodexAppServerError => +const normalizeIncomingError = ( + error: unknown, + operation: CodexError.CodexAppServerTransportOperation, +): CodexError.CodexAppServerError => isCodexAppServerError(error) ? error : new CodexError.CodexAppServerTransportError({ - detail, + operation, cause: error, }); @@ -143,9 +155,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const outgoing = yield* Queue.unbounded>(); const incomingNotifications = yield* Queue.unbounded(); const incomingRequests = yield* Queue.unbounded(); - const pending = yield* Ref.make( - new Map>(), - ); + const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); const remainder = yield* Ref.make(""); const terminationHandled = yield* Ref.make(false); @@ -166,7 +176,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const failAllPending = (error: CodexError.CodexAppServerError) => Ref.get(pending).pipe( Effect.flatMap((current) => - Effect.forEach([...current.values()], (deferred) => Deferred.fail(deferred, error), { + Effect.forEach([...current.values()], ({ deferred }) => Deferred.fail(deferred, error), { discard: true, }), ), @@ -219,18 +229,16 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const resolvePending = ( requestId: string, - handler: ( - deferred: Deferred.Deferred, - ) => Effect.Effect, + handler: (pendingRequest: CodexAppServerPendingRequest) => Effect.Effect, ) => Ref.modify(pending, (current) => { - const deferred = current.get(requestId); - if (!deferred) { + const pendingRequest = current.get(requestId); + if (!pendingRequest) { return [Effect.void, current] as const; } const next = new Map(current); next.delete(requestId); - return [handler(deferred), next] as const; + return [handler(pendingRequest), next] as const; }).pipe(Effect.flatten); const respond = (requestId: string | number, result: unknown) => @@ -245,14 +253,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const requestId = String(response.id); const protocolError = response.error; if (protocolError !== undefined) { - return resolvePending(requestId, (deferred) => + return resolvePending(requestId, ({ deferred, method }) => Deferred.fail( deferred, - CodexError.CodexAppServerRequestError.fromProtocolError(protocolError), + CodexError.CodexAppServerRequestError.fromProtocolError( + protocolError, + method, + requestId, + ), ), ); } - return resolvePending(requestId, (deferred) => Deferred.succeed(deferred, response.result)); + return resolvePending(requestId, ({ deferred }) => + Deferred.succeed(deferred, response.result), + ); }; const handleRequest = (request: CodexAppServerIncomingRequest) => @@ -262,7 +276,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa ? options.onRequest(request).pipe( Effect.matchEffect({ onFailure: (error) => - respondError(request.id, CodexError.normalizeToRequestError(error)), + respondError( + request.id, + CodexError.CodexAppServerRequestError.fromAppServerError( + error, + request.method, + ), + ), onSuccess: (result) => respond(request.id, result), }), ) @@ -290,9 +310,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa return handleResponse(message); } return Effect.fail( - new CodexError.CodexAppServerProtocolParseError({ - detail: "Received protocol message in an unknown shape", - }), + CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message), ); }; @@ -318,8 +336,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa direction: "incoming", stage: "decode_failed", payload: { - detail: error.detail, - cause: error.cause, + operation: error.operation, + ...(error.method === undefined ? {} : { method: error.method }), + ...(error.requestId === undefined ? {} : { requestId: error.requestId }), + ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), + ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), + ...(error.maximumPathDepth === undefined + ? {} + : { maximumPathDepth: error.maximumPathDepth }), }, }), ), @@ -340,7 +364,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.matchEffect({ onFailure: (error) => handleTermination(() => - Effect.succeed(normalizeIncomingError(error, "Codex App Server input stream failed")), + Effect.succeed(normalizeIncomingError(error, "read-input-stream")), ), onSuccess: () => Ref.get(remainder).pipe( @@ -351,12 +375,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa handleTermination( () => options.terminationError ?? - Effect.succeed( - new CodexError.CodexAppServerTransportError({ - detail: "Codex App Server input stream ended", - cause: new Error("Codex App Server input stream ended"), - }), - ), + Effect.succeed(new CodexError.CodexAppServerInputStreamEndedError({})), ), }), ), @@ -373,16 +392,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa (current) => [current, current + 1] as const, ); const deferred = yield* Deferred.make(); - yield* Ref.update(pending, (current) => new Map(current).set(String(requestId), deferred)); + yield* Ref.update(pending, (current) => + new Map(current).set(String(requestId), { deferred, method }), + ); yield* offerOutgoing({ id: requestId, method, ...(payload !== undefined ? { params: payload } : {}), - }).pipe( - Effect.catch((error) => - removePending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), - ), - ); + }).pipe(Effect.tapError(() => removePending(String(requestId)))); return yield* Deferred.await(deferred).pipe( Effect.onInterrupt(() => removePending(String(requestId))), ); diff --git a/packages/shared/package.json b/packages/shared/package.json index 2360b7c2a21..6a3a166f0c1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -163,17 +163,25 @@ "types": "./src/relayClient.ts", "import": "./src/relayClient.ts" }, + "./relayTracing": { + "types": "./src/relayTracing.ts", + "import": "./src/relayTracing.ts" + }, "./preview": { "types": "./src/preview.ts", "import": "./src/preview.ts" }, + "./filePreview": { + "types": "./src/filePreview.ts", + "import": "./src/filePreview.ts" + }, + "./chatList": { + "types": "./src/chatList.ts", + "import": "./src/chatList.ts" + }, "./hostProcess": { "types": "./src/hostProcess.ts", "import": "./src/hostProcess.ts" - }, - "./relayTracing": { - "types": "./src/relayTracing.ts", - "import": "./src/relayTracing.ts" } }, "scripts": { diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index e165d944b56..93a1649b25b 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -81,9 +81,9 @@ it.layer(NetService.layer)("NetService", (it) => { }), ); - it.effect("findAvailablePort falls back when preferred is occupied", () => + it.effect("findAvailablePort falls back when a wildcard listener occupies IPv4", () => Effect.acquireUseRelease( - openServer(), + openServer("0.0.0.0"), (server) => Effect.gen(function* () { const net = yield* NetService.NetService; diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 0a3c6283756..d7713a72612 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -28,40 +28,6 @@ const closeServer = (server: NodeNet.Server) => { } }; -const tryReservePort = (port: number): Effect.Effect => - Effect.callback((resume) => { - const server = NodeNet.createServer(); - let settled = false; - - const settle = (effect: Effect.Effect) => { - if (settled) return; - settled = true; - resume(effect); - }; - - server.unref(); - - server.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Could not find an available port.", cause }))); - }); - - server.listen(port, () => { - const address = server.address(); - const resolved = typeof address === "object" && address !== null ? address.port : 0; - server.close(() => { - if (resolved > 0) { - settle(Effect.succeed(resolved)); - return; - } - settle(Effect.fail(new NetError({ message: "Could not find an available port." }))); - }); - }); - - return Effect.sync(() => { - closeServer(server); - }); - }); - export interface NetServiceShape { /** * Returns true when a TCP server can bind to {host, port}. @@ -131,6 +97,53 @@ export const make = () => { }); }); + const hasListenerOnHost = (port: number, host: string): Effect.Effect => + Effect.callback((resume) => { + const socket = NodeNet.createConnection({ host, port }); + let settled = false; + + const settle = (value: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resume(Effect.succeed(value)); + }; + + socket.unref(); + socket.setTimeout(250); + socket.once("connect", () => { + settle(true); + }); + socket.once("error", () => { + settle(false); + }); + socket.once("timeout", () => { + settle(false); + }); + + return Effect.sync(() => { + socket.destroy(); + }); + }); + + const isPortAvailableOnLoopback = (port: number): Effect.Effect => + Effect.gen(function* () { + const hasListener = yield* Effect.zipWith( + hasListenerOnHost(port, "127.0.0.1"), + hasListenerOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 || ipv6, + ); + if (hasListener) { + return false; + } + + return yield* Effect.zipWith( + canListenOnHost(port, "127.0.0.1"), + canListenOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 && ipv6, + ); + }); + /** * Reserve an ephemeral loopback port and release it immediately. * Returns the reserved port number. @@ -169,15 +182,15 @@ export const make = () => { return { canListenOnHost, - isPortAvailableOnLoopback: (port) => - Effect.zipWith( - canListenOnHost(port, "127.0.0.1"), - canListenOnHost(port, "::1"), - (ipv4, ipv6) => ipv4 && ipv6, - ), + isPortAvailableOnLoopback, reserveLoopbackPort, findAvailablePort: (preferred) => - Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), + Effect.gen(function* () { + if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) { + return preferred; + } + return yield* reserveLoopbackPort(); + }), } satisfies NetServiceShape; }; diff --git a/packages/shared/src/chatList.test.ts b/packages/shared/src/chatList.test.ts new file mode 100644 index 00000000000..58e0e03f026 --- /dev/null +++ b/packages/shared/src/chatList.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "./chatList.js"; + +interface Row { + readonly id: string; + readonly anchorable: boolean; +} + +const rows: ReadonlyArray = [ + { id: "first", anchorable: true }, + { id: "ignored", anchorable: false }, + { id: "latest", anchorable: true }, +]; + +const getAnchorId = (row: Row) => (row.anchorable ? row.id : null); + +describe("resolveChatListAnchoredEndSpace", () => { + it("anchors the matching row using its measured height", () => { + expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({ + anchorIndex: 2, + anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + }); + }); + + it("allows a surface to keep the anchor below its own header", () => { + expect( + resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId, { + anchorOffset: 132, + }), + ).toEqual({ + anchorIndex: 2, + anchorOffset: 132, + }); + }); + + it("ignores ineligible rows and missing anchors", () => { + expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined(); + expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined(); + expect(resolveChatListAnchoredEndSpace(rows, null, getAnchorId)).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/chatList.ts b/packages/shared/src/chatList.ts new file mode 100644 index 00000000000..a034cd02f31 --- /dev/null +++ b/packages/shared/src/chatList.ts @@ -0,0 +1,33 @@ +export const CHAT_LIST_ANCHOR_OFFSET = 16; + +export interface ChatListAnchoredEndSpace { + readonly anchorIndex: number; + readonly anchorOffset: number; +} + +export interface ChatListAnchorOptions { + readonly anchorOffset?: number; +} + +export function resolveChatListAnchoredEndSpace( + items: ReadonlyArray, + anchorId: AnchorId | null, + getAnchorId: (item: Item) => AnchorId | null, + options: ChatListAnchorOptions = {}, +): ChatListAnchoredEndSpace | undefined { + if (anchorId === null) { + return undefined; + } + + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item !== undefined && getAnchorId(item) === anchorId) { + return { + anchorIndex: index, + anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, + }; + } + } + + return undefined; +} diff --git a/packages/shared/src/dpop.test.ts b/packages/shared/src/dpop.test.ts index 58bd161e2a3..c4ba298f66c 100644 --- a/packages/shared/src/dpop.test.ts +++ b/packages/shared/src/dpop.test.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "node:crypto"; -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import { computeDpopAccessTokenHash, @@ -56,59 +56,93 @@ describe("verifyDpopProof", () => { it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); - expect( - verifyDpopProof({ - proof, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - }), - ).toMatchObject({ - ok: true, - thumbprint, - jti: "proof-1", + const result = verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }); + + if (!result.ok) { + assert.fail(result.reason); + } + assert.equal(result.thumbprint, thumbprint); + assert.equal(result.jti, "proof-1"); + }); + + it("rejects malformed DPoP header and payload JSON", () => { + const [header, payload, signature] = proof.split("."); + if (!header || !payload || !signature) { + assert.fail("Expected the test DPoP proof to use compact JWT format."); + } + const malformedJson = Buffer.from("{").toString("base64url"); + + const malformedHeader = verifyDpopProof({ + proof: `${malformedJson}.${payload}.${signature}`, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, }); + if (malformedHeader.ok) { + assert.fail("Expected malformed DPoP header JSON to fail."); + } + assert.equal(malformedHeader.reason, "Invalid DPoP JWT header."); + + const malformedPayload = verifyDpopProof({ + proof: `${header}.${malformedJson}.${signature}`, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + }); + if (malformedPayload.ok) { + assert.fail("Expected malformed DPoP payload JSON to fail."); + } + assert.equal(malformedPayload.reason, "Invalid DPoP JWT payload."); }); it("rejects method, URL, thumbprint, and time-window mismatches", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); - expect( + assert.equal( verifyDpopProof({ proof, method: "GET", url: "https://example.com/oauth/token", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/other", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/oauth/token", nowEpochSeconds: 101, expectedThumbprint: "other-thumbprint", - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/oauth/token", nowEpochSeconds: 1_000, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); + }).ok, + false, + ); }); it("requires the RFC 9449 access token hash when an access token is expected", () => { @@ -122,7 +156,7 @@ describe("verifyDpopProof", () => { accessToken: "clerk-access-token", }); - expect( + assert.equal( verifyDpopProof({ proof: accessTokenProof, method: "POST", @@ -130,32 +164,40 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: thumbprint, expectedAccessToken: "clerk-access-token", - }), - ).toMatchObject({ ok: true }); - expect( - verifyDpopProof({ - proof, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - expectedAccessToken: "clerk-access-token", - }), - ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); - expect( - verifyDpopProof({ - proof: accessTokenProof, - method: "POST", - url: "https://example.com/v1/environments/env/connect", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - expectedAccessToken: "other-access-token", - }), - ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); + }).ok, + true, + ); + + const missingHash = verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "clerk-access-token", + }); + if (missingHash.ok) { + assert.fail("Expected DPoP proof without an access token hash to fail."); + } + assert.equal(missingHash.reason, "DPoP access token hash mismatch."); + + const mismatchedHash = verifyDpopProof({ + proof: accessTokenProof, + method: "POST", + url: "https://example.com/v1/environments/env/connect", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "other-access-token", + }); + if (mismatchedHash.ok) { + assert.fail("Expected DPoP proof with a mismatched access token hash to fail."); + } + assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch."); }); it("normalizes htu by excluding query and fragment components per RFC 9449", () => { - expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe( + assert.equal( + normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"), "https://example.com/v1/environments/env/connect", ); @@ -168,15 +210,16 @@ describe("verifyDpopProof", () => { publicJwk, }); - expect( + assert.equal( verifyDpopProof({ proof: queryProof, method: "POST", url: "https://example.com/v1/environments/env/connect?foo=bar#frag", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: true }); + }).ok, + true, + ); }); it("rejects DPoP public JWK headers that expose private key material", () => { @@ -192,14 +235,17 @@ describe("verifyDpopProof", () => { publicJwk: privateJwk, }); - expect( - verifyDpopProof({ - proof: proofWithPrivateJwk, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." }); + const result = verifyDpopProof({ + proof: proofWithPrivateJwk, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }); + + if (result.ok) { + assert.fail("Expected DPoP proof with private JWK material to fail."); + } + assert.equal(result.reason, "Invalid DPoP JWT header."); }); }); diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 34210679007..88dcf8e3090 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -1,6 +1,7 @@ import { p256 } from "@noble/curves/nist"; import { sha256 } from "@noble/hashes/sha2"; import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -17,21 +18,31 @@ export const DpopPublicJwk = Schema.Struct({ y: Schema.String.check(Schema.isNonEmpty()), }); export type DpopPublicJwk = typeof DpopPublicJwk.Type; -const isDpopPublicJwk = Schema.is(DpopPublicJwk); -interface DpopJwtHeader { - readonly typ: string; - readonly alg: string; - readonly jwk: DpopPublicJwk; -} +const DpopJwtHeaderPublicJwk = Schema.Struct({ + ...DpopPublicJwk.fields, + d: Schema.optionalKey(Schema.Never), +}); -interface DpopJwtPayload { - readonly htm: string; - readonly htu: string; - readonly jti: string; - readonly iat: number; - readonly ath?: string; -} +const DpopJwtHeaderJson = Schema.fromJsonString( + Schema.Struct({ + typ: Schema.Literal(DPOP_TYP), + alg: Schema.Literal(DPOP_ALG), + jwk: DpopJwtHeaderPublicJwk, + }), +); +const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(DpopJwtHeaderJson); + +const DpopJwtPayloadJson = Schema.fromJsonString( + Schema.Struct({ + htm: Schema.String.check(Schema.isNonEmpty()), + htu: Schema.String.check(Schema.isNonEmpty()), + jti: Schema.String.check(Schema.isNonEmpty()), + iat: Schema.Int, + ath: Schema.optionalKey(Schema.String), + }), +); +const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(DpopJwtPayloadJson); export type DpopVerificationResult = | { @@ -49,40 +60,12 @@ function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Encoding.decodeBase64Url(value)); } -function decodeBase64UrlJson(value: string): unknown { - return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown; +function decodeBase64UrlDpopJwtHeader(value: string) { + return decodeDpopJwtHeaderJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); } -function isDpopJwtHeader(value: unknown): value is DpopJwtHeader { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - return ( - record.typ === DPOP_TYP && - record.alg === DPOP_ALG && - typeof record.jwk === "object" && - record.jwk !== null && - !("d" in record.jwk) && - isDpopPublicJwk(record.jwk) - ); -} - -function isDpopJwtPayload(value: unknown): value is DpopJwtPayload { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - return ( - typeof record.htm === "string" && - record.htm.length > 0 && - typeof record.htu === "string" && - record.htu.length > 0 && - typeof record.jti === "string" && - record.jti.length > 0 && - typeof record.iat === "number" && - Number.isInteger(record.iat) - ); +function decodeBase64UrlDpopJwtPayload(value: string) { + return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); } function dpopThumbprintInput(jwk: DpopPublicJwk): string { @@ -145,53 +128,58 @@ export function verifyDpopProof(input: { } try { - const header = decodeBase64UrlJson(parts[0]); - const payload = decodeBase64UrlJson(parts[1]); - if (!isDpopJwtHeader(header)) { + const header = decodeBase64UrlDpopJwtHeader(parts[0]); + const payload = decodeBase64UrlDpopJwtPayload(parts[1]); + if (Option.isNone(header)) { return { ok: false, reason: "Invalid DPoP JWT header." }; } - if (!isDpopJwtPayload(payload)) { + if (Option.isNone(payload)) { return { ok: false, reason: "Invalid DPoP JWT payload." }; } - const thumbprint = computeDpopJwkThumbprint(header.jwk); + const thumbprint = computeDpopJwkThumbprint(header.value.jwk); if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) { return { ok: false, reason: "DPoP key thumbprint mismatch." }; } - if (payload.htm.toUpperCase() !== input.method.toUpperCase()) { + if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) { return { ok: false, reason: "DPoP method mismatch." }; } const normalizedHtu = normalizeDpopHtu(input.url); - if (normalizedHtu === null || payload.htu !== normalizedHtu) { + if (normalizedHtu === null || payload.value.htu !== normalizedHtu) { return { ok: false, reason: "DPoP URL mismatch." }; } if (input.expectedAccessToken) { const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken); - if (payload.ath !== expectedAth) { + if (payload.value.ath !== expectedAth) { return { ok: false, reason: "DPoP access token hash mismatch." }; } } const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; if ( - payload.iat > input.nowEpochSeconds + 5 || - input.nowEpochSeconds - payload.iat > maxAgeSeconds + payload.value.iat > input.nowEpochSeconds + 5 || + input.nowEpochSeconds - payload.value.iat > maxAgeSeconds ) { return { ok: false, reason: "DPoP proof is outside the allowed time window." }; } const signature = base64UrlToBytes(parts[2]); const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); - const verified = p256.verify(signature, signatureInputHash, publicKeyBytesFromJwk(header.jwk), { - prehash: false, - format: "compact", - }); + const verified = p256.verify( + signature, + signatureInputHash, + publicKeyBytesFromJwk(header.value.jwk), + { + prehash: false, + format: "compact", + }, + ); return verified ? { ok: true, thumbprint, - jti: payload.jti, - iat: payload.iat, + jti: payload.value.jti, + iat: payload.value.iat, } : { ok: false, reason: "Invalid DPoP signature." }; } catch { diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts new file mode 100644 index 00000000000..eb8b7d1e892 --- /dev/null +++ b/packages/shared/src/filePreview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, + isWorkspacePreviewEntryPath, +} from "./filePreview.ts"; + +describe("workspace file previews", () => { + it.each(["report.html", "report.HTM", "document.pdf?download=1"])( + "recognizes browser preview path %s", + (path) => { + expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); + expect(isWorkspacePreviewEntryPath(path)).toBe(true); + }, + ); + + it.each([ + "icon.png", + "photo.JPEG", + "animation.gif", + "vector.svg#mark", + "texture.webp", + "image.avif", + ])("recognizes image preview path %s", (path) => { + expect(isWorkspaceImagePreviewPath(path)).toBe(true); + expect(isWorkspacePreviewEntryPath(path)).toBe(true); + }); + + it.each(["README.md", "src/index.ts", "image.png.ts", "png"])( + "rejects non-preview path %s", + (path) => { + expect(isWorkspacePreviewEntryPath(path)).toBe(false); + }, + ); +}); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts new file mode 100644 index 00000000000..c9d15e14c3b --- /dev/null +++ b/packages/shared/src/filePreview.ts @@ -0,0 +1,29 @@ +export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const; + +export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ + ".avif", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".webp", +] as const; + +function hasPreviewExtension(path: string, extensions: ReadonlyArray): boolean { + const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; + return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); +} + +export function isWorkspaceBrowserPreviewPath(path: string): boolean { + return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS); +} + +export function isWorkspaceImagePreviewPath(path: string): boolean { + return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS); +} + +export function isWorkspacePreviewEntryPath(path: string): boolean { + return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path); +} diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 4abe53f2053..b6bdd7b4783 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -19,6 +19,7 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+b", command: "sidebar.toggle" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, diff --git a/packages/shared/src/logging.test.ts b/packages/shared/src/logging.test.ts new file mode 100644 index 00000000000..0e1ea2738bc --- /dev/null +++ b/packages/shared/src/logging.test.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + RotatingFileSink, + RotatingFileSinkConfigurationError, + RotatingFileSinkError, +} from "./logging.ts"; + +const tempDirectories: string[] = []; + +const makeTempDirectory = (): string => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-logging-")); + tempDirectories.push(directory); + return directory; +}; + +const captureError = (run: () => unknown): unknown => { + try { + run(); + } catch (cause) { + return cause; + } + throw new Error("Expected operation to throw"); +}; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("RotatingFileSink", () => { + it.each([ + { option: "maxBytes" as const, maxBytes: 0, maxFiles: 1 }, + { option: "maxFiles" as const, maxBytes: 1, maxFiles: 0 }, + ])("reports invalid $option configuration structurally", (input) => { + const thrown = captureError( + () => + new RotatingFileSink({ + filePath: "/unused/log.ndjson", + maxBytes: input.maxBytes, + maxFiles: input.maxFiles, + }), + ); + + expect(thrown).toBeInstanceOf(RotatingFileSinkConfigurationError); + expect(thrown).toMatchObject({ + option: input.option, + received: 0, + minimum: 1, + }); + expect((thrown as Error).message).toBe(`${input.option} must be >= 1 (received 0)`); + }); + + it("preserves directory initialization failures", () => { + const directory = makeTempDirectory(); + const parentFile = NodePath.join(directory, "not-a-directory"); + const filePath = NodePath.join(parentFile, "log.ndjson"); + NodeFS.writeFileSync(parentFile, "occupied"); + + const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 })); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "initialize", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); + + it("only treats a missing log file as an empty current size", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "a".repeat(300)); + + const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 })); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "read", filePath }); + expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "ENAMETOOLONG" }); + }); + + it("starts an absent log file at zero bytes", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + const sink = new RotatingFileSink({ filePath, maxBytes: 100, maxFiles: 1 }); + + sink.write("entry"); + + expect(NodeFS.readFileSync(filePath, "utf8")).toBe("entry"); + }); + + it("preserves write failures", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + NodeFS.mkdirSync(filePath); + const sink = new RotatingFileSink({ + filePath, + maxBytes: Number.MAX_SAFE_INTEGER, + maxFiles: 1, + throwOnError: true, + }); + + const thrown = captureError(() => sink.write("entry")); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "write", filePath }); + expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "EISDIR" }); + }); + + it("preserves rotation failures without an artificial write wrapper", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + NodeFS.writeFileSync(filePath, "a"); + NodeFS.mkdirSync(`${filePath}.1`); + const sink = new RotatingFileSink({ + filePath, + maxBytes: 1, + maxFiles: 1, + throwOnError: true, + }); + + const thrown = captureError(() => sink.write("b")); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "rotate", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); + + it("preserves backup pruning failures", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + const overflowBackup = `${filePath}.2`; + NodeFS.mkdirSync(overflowBackup); + NodeFS.writeFileSync(NodePath.join(overflowBackup, "entry"), "occupied"); + + const thrown = captureError( + () => + new RotatingFileSink({ + filePath, + maxBytes: 1, + maxFiles: 1, + throwOnError: true, + }), + ); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "prune", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); +}); diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts index 8e1d1019e1d..4aa9c7843a6 100644 --- a/packages/shared/src/logging.ts +++ b/packages/shared/src/logging.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as Schema from "effect/Schema"; export interface RotatingFileSinkOptions { readonly filePath: string; @@ -9,6 +10,37 @@ export interface RotatingFileSinkOptions { readonly throwOnError?: boolean; } +export class RotatingFileSinkConfigurationError extends Schema.TaggedErrorClass()( + "RotatingFileSinkConfigurationError", + { + option: Schema.Literals(["maxBytes", "maxFiles"]), + received: Schema.Number, + minimum: Schema.Number, + }, +) { + override get message(): string { + return `${this.option} must be >= ${this.minimum} (received ${this.received})`; + } +} + +export class RotatingFileSinkError extends Schema.TaggedErrorClass()( + "RotatingFileSinkError", + { + operation: Schema.Literals(["initialize", "read", "write", "rotate", "prune"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} rotating log file ${this.filePath}`; + } +} + +const isRotatingFileSinkError = Schema.is(RotatingFileSinkError); + +const isFileNotFoundError = (cause: unknown): cause is NodeJS.ErrnoException => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + export class RotatingFileSink { private readonly filePath: string; private readonly maxBytes: number; @@ -18,10 +50,18 @@ export class RotatingFileSink { constructor(options: RotatingFileSinkOptions) { if (options.maxBytes < 1) { - throw new Error(`maxBytes must be >= 1 (received ${options.maxBytes})`); + throw new RotatingFileSinkConfigurationError({ + option: "maxBytes", + received: options.maxBytes, + minimum: 1, + }); } if (options.maxFiles < 1) { - throw new Error(`maxFiles must be >= 1 (received ${options.maxFiles})`); + throw new RotatingFileSinkConfigurationError({ + option: "maxFiles", + received: options.maxFiles, + minimum: 1, + }); } this.filePath = options.filePath; @@ -29,7 +69,15 @@ export class RotatingFileSink { this.maxFiles = options.maxFiles; this.throwOnError = options.throwOnError ?? false; - fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + try { + NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true }); + } catch (cause) { + throw new RotatingFileSinkError({ + operation: "initialize", + filePath: this.filePath, + cause, + }); + } this.pruneOverflowBackups(); this.currentSize = this.readCurrentSize(); } @@ -43,70 +91,92 @@ export class RotatingFileSink { this.rotate(); } - fs.appendFileSync(this.filePath, buffer); + NodeFS.appendFileSync(this.filePath, buffer); this.currentSize += buffer.length; if (this.currentSize > this.maxBytes) { this.rotate(); } - } catch { - this.currentSize = this.readCurrentSize(); + } catch (cause) { + if (isRotatingFileSinkError(cause)) { + throw cause; + } if (this.throwOnError) { - throw new Error(`Failed to write log chunk to ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "write", + filePath: this.filePath, + cause, + }); } + this.currentSize = this.readCurrentSize(); } } private rotate(): void { try { const oldest = this.withSuffix(this.maxFiles); - if (fs.existsSync(oldest)) { - fs.rmSync(oldest, { force: true }); + if (NodeFS.existsSync(oldest)) { + NodeFS.rmSync(oldest, { force: true }); } for (let index = this.maxFiles - 1; index >= 1; index -= 1) { const source = this.withSuffix(index); const target = this.withSuffix(index + 1); - if (fs.existsSync(source)) { - fs.renameSync(source, target); + if (NodeFS.existsSync(source)) { + NodeFS.renameSync(source, target); } } - if (fs.existsSync(this.filePath)) { - fs.renameSync(this.filePath, this.withSuffix(1)); + if (NodeFS.existsSync(this.filePath)) { + NodeFS.renameSync(this.filePath, this.withSuffix(1)); } this.currentSize = 0; - } catch { - this.currentSize = this.readCurrentSize(); + } catch (cause) { if (this.throwOnError) { - throw new Error(`Failed to rotate log file ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "rotate", + filePath: this.filePath, + cause, + }); } + this.currentSize = this.readCurrentSize(); } } private pruneOverflowBackups(): void { try { - const dir = path.dirname(this.filePath); - const baseName = path.basename(this.filePath); - for (const entry of fs.readdirSync(dir)) { + const dir = NodePath.dirname(this.filePath); + const baseName = NodePath.basename(this.filePath); + for (const entry of NodeFS.readdirSync(dir)) { if (!entry.startsWith(`${baseName}.`)) continue; const suffix = Number(entry.slice(baseName.length + 1)); if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue; - fs.rmSync(path.join(dir, entry), { force: true }); + NodeFS.rmSync(NodePath.join(dir, entry), { force: true }); } - } catch { + } catch (cause) { if (this.throwOnError) { - throw new Error(`Failed to prune log backups for ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "prune", + filePath: this.filePath, + cause, + }); } } } private readCurrentSize(): number { try { - return fs.statSync(this.filePath).size; - } catch { - return 0; + return NodeFS.statSync(this.filePath).size; + } catch (cause) { + if (isFileNotFoundError(cause)) { + return 0; + } + throw new RotatingFileSinkError({ + operation: "read", + filePath: this.filePath, + cause, + }); } } diff --git a/packages/shared/src/oauthScope.test.ts b/packages/shared/src/oauthScope.test.ts index 0aa4ef595a8..f5cc247a24a 100644 --- a/packages/shared/src/oauthScope.test.ts +++ b/packages/shared/src/oauthScope.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; -import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts"; +import { + encodeOAuthScope, + OAuthScopeEncodingError, + parseAllowedOAuthScope, + parseOAuthScope, +} from "./oauthScope.ts"; + +const isOAuthScopeEncodingError = Schema.is(OAuthScopeEncodingError); describe("OAuth scopes", () => { it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => { @@ -32,4 +40,22 @@ describe("OAuth scopes", () => { }), ).toBeNull(); }); + + it("reports invalid encoding input structurally", () => { + expect.assertions(5); + + try { + encodeOAuthScope(["access:read", "invalid scope", "access:read"]); + } catch (error) { + expect(error).toBeInstanceOf(OAuthScopeEncodingError); + if (!isOAuthScopeEncodingError(error)) return; + + expect(error.scopes).toEqual(["access:read", "invalid scope", "access:read"]); + expect(error.invalidScopes).toEqual(["invalid scope"]); + expect(error.duplicateScopes).toEqual(["access:read"]); + expect(error.message).toBe( + "OAuth scopes must be non-empty, syntactically valid, and unique.", + ); + } + }); }); diff --git a/packages/shared/src/oauthScope.ts b/packages/shared/src/oauthScope.ts index 47c6dd7051b..4f427440660 100644 --- a/packages/shared/src/oauthScope.ts +++ b/packages/shared/src/oauthScope.ts @@ -1,5 +1,20 @@ +import * as Schema from "effect/Schema"; + const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; +export class OAuthScopeEncodingError extends Schema.TaggedErrorClass()( + "OAuthScopeEncodingError", + { + scopes: Schema.Array(Schema.String), + invalidScopes: Schema.Array(Schema.String), + duplicateScopes: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return "OAuth scopes must be non-empty, syntactically valid, and unique."; + } +} + /** * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen * order for canonical responses and logs. @@ -18,12 +33,22 @@ export function parseOAuthScope(value: string): ReadonlyArray | null { } export function encodeOAuthScope(scopes: ReadonlyArray): string { - const encoded = scopes.join(" "); - const parsed = parseOAuthScope(encoded); - if (parsed === null || parsed.length !== scopes.length) { - throw new Error("OAuth scopes must be non-empty, valid, and unique."); + const invalidScopes = scopes.filter((scope) => !OAUTH_SCOPE_TOKEN.test(scope)); + const seen = new Set(); + const duplicateScopes = new Set(); + for (const scope of scopes) { + if (seen.has(scope)) duplicateScopes.add(scope); + seen.add(scope); + } + + if (scopes.length === 0 || invalidScopes.length > 0 || duplicateScopes.size > 0) { + throw new OAuthScopeEncodingError({ + scopes, + invalidScopes, + duplicateScopes: [...duplicateScopes], + }); } - return encoded; + return scopes.join(" "); } export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray): boolean { diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 57537b63e19..f9cf1b1cbcf 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -15,11 +15,20 @@ import * as Tracer from "effect/Tracer"; import { causeErrorTag, compactTraceAttributes, + errorTag, makeLocalFileTracer, makeTraceSink, type TraceRecord, } from "./observability.ts"; +describe("errorTag", () => { + it("reports structural tags without retaining arbitrary values", () => { + assert.equal(errorTag({ _tag: "AcpRequestError" }), "AcpRequestError"); + assert.equal(errorTag(new TypeError("secret-token-value")), "TypeError"); + assert.equal(errorTag({ _tag: "secret token value" }), "TaggedError"); + }); +}); + describe("causeErrorTag", () => { it("reports the tagged failure value instead of the Cause reason wrapper", () => { assert.equal( diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 68d4985db95..1b92b98739d 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -73,18 +73,33 @@ export interface OtlpTraceRecord extends BaseTraceRecord { export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; -function taggedErrorName(error: unknown): string { - return typeof error === "object" && error !== null && "_tag" in error - ? String(error._tag) - : error instanceof Error - ? error.name - : typeof error; +function isStructuralTag(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 128 && + /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) + ); +} + +export function errorTag(error: unknown): string { + try { + if (typeof error === "object" && error !== null && "_tag" in error) { + return isStructuralTag(error._tag) ? error._tag : "TaggedError"; + } + if (error instanceof Error) { + return isStructuralTag(error.name) ? error.name : "Error"; + } + } catch { + return "UnknownError"; + } + return typeof error; } export function causeErrorTag(cause: Cause.Cause): string { const failure = Cause.findErrorOption(cause); if (Option.isSome(failure)) { - return taggedErrorName(failure.value); + return errorTag(failure.value); } return cause.reasons[0]?._tag ?? "Empty"; } diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index 6030686d3ed..fec4203c533 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -61,15 +61,51 @@ describe("normalizePreviewUrl", () => { }); it("rejects empty input", () => { - expect(() => normalizePreviewUrl(" ")).toThrow(PreviewUrlNormalizationError); + try { + normalizePreviewUrl(" "); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ inputLength: 3, reason: "empty" }); + expect(error).not.toHaveProperty("rawUrl"); + expect("cause" in (error as object)).toBe(false); + } }); it("rejects unsupported protocols", () => { - expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError); - expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError); + try { + normalizePreviewUrl("ftp://example.com"); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ + inputLength: "ftp://example.com".length, + reason: "unsupported-protocol", + protocol: "ftp:", + }); + } }); - it("rejects unparseable junk", () => { - expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError); + it("rejects unparseable input without retaining credentials or tokens", () => { + const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; + try { + normalizePreviewUrl(rawUrl); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ + inputLength: rawUrl.length, + reason: "parse", + protocol: "https:", + }); + expect(error).not.toHaveProperty("rawUrl"); + expect((error as PreviewUrlNormalizationError).cause).toBeInstanceOf(Error); + expect((error as PreviewUrlNormalizationError).message).not.toContain( + ((error as PreviewUrlNormalizationError).cause as Error).message, + ); + expect((error as PreviewUrlNormalizationError).message).not.toMatch( + /user|password|access_token|secret|fragment/, + ); + } }); }); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index cc5a765ddcb..926b30966e5 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -4,6 +4,8 @@ * on what counts as "loopback" and how to normalise a free-form URL string. */ +import * as Schema from "effect/Schema"; + const TAB_ID_PREFIX = "tab_"; let nextPreviewTabSequence = 0; @@ -45,17 +47,27 @@ export function isPreviewableUrl(rawUrl: string): boolean { } } -export class PreviewUrlNormalizationError extends Error { - readonly rawUrl: string; - readonly detail: string; - constructor(rawUrl: string, detail: string) { - super(`Invalid preview URL: ${rawUrl} (${detail})`); - this.name = "PreviewUrlNormalizationError"; - this.rawUrl = rawUrl; - this.detail = detail; +export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass()( + "PreviewUrlNormalizationError", + { + inputLength: Schema.Number, + reason: Schema.Literals(["empty", "parse", "unsupported-protocol"]), + protocol: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`; + return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`; } } +export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError); + +function previewUrlProtocol(rawUrl: string): string | undefined { + return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); +} + /** * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. * @@ -69,7 +81,7 @@ export class PreviewUrlNormalizationError extends Error { export function normalizePreviewUrl(rawUrl: string): string { const trimmed = rawUrl.trim(); if (trimmed.length === 0) { - throw new PreviewUrlNormalizationError(rawUrl, "empty"); + throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" }); } const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); const candidate = trimmed.includes("://") @@ -79,13 +91,19 @@ export function normalizePreviewUrl(rawUrl: string): string { try { parsed = new URL(candidate); } catch (cause) { - throw new PreviewUrlNormalizationError( - rawUrl, - cause instanceof Error ? cause.message : "unparseable", - ); + throw new PreviewUrlNormalizationError({ + inputLength: rawUrl.length, + reason: "parse", + protocol: previewUrlProtocol(candidate), + cause, + }); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new PreviewUrlNormalizationError(rawUrl, `unsupported protocol ${parsed.protocol}`); + throw new PreviewUrlNormalizationError({ + inputLength: rawUrl.length, + reason: "unsupported-protocol", + protocol: parsed.protocol, + }); } return parsed.href; } diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts index dc06ce5323c..3abff9b5210 100644 --- a/packages/shared/src/relayAuth.test.ts +++ b/packages/shared/src/relayAuth.test.ts @@ -1,17 +1,79 @@ import { describe, expect, it } from "vite-plus/test"; import { + ClerkPublishableKeyDecodeError, + ClerkPublishableKeyFrontendApiError, clerkFrontendApiHostnameFromPublishableKey, + clerkFrontendApiUrlFromPublishableKey, isAllowedClerkFrontendApiHostname, } from "./relayAuth.ts"; const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; +const captureError = (run: () => unknown): unknown => { + try { + run(); + } catch (cause) { + return cause; + } + throw new Error("Expected operation to throw"); +}; + describe("Clerk relay auth", () => { it("derives a custom Frontend API hostname from a Clerk publishable key", () => { expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( "clerk.t3.codes", ); + expect(clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( + "https://clerk.t3.codes", + ); + }); + + it("preserves Clerk publishable key decoding failures", () => { + const error = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_%")); + + expect(error).toBeInstanceOf(ClerkPublishableKeyDecodeError); + expect(error).toMatchObject({ keyPrefix: "pk_test" }); + expect((error as ClerkPublishableKeyDecodeError).cause).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Failed to decode Clerk publishable key (pk_test)."); + }); + + it("reports semantic frontend API failures without inventing a cause", () => { + const emptyError = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_")); + const pathFrontendApi = "clerk.t3.codes/path"; + const pathError = captureError(() => + clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey(pathFrontendApi)), + ); + + expect(emptyError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(emptyError).toMatchObject({ + keyPrefix: "pk_test", + frontendApi: "", + reason: "empty", + }); + expect((emptyError as Error & { cause?: unknown }).cause).toBeUndefined(); + expect(pathError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(pathError).toMatchObject({ + keyPrefix: "pk_test", + frontendApi: pathFrontendApi, + reason: "contains-path", + }); + expect((pathError as Error & { cause?: unknown }).cause).toBeUndefined(); + }); + + it("preserves URL parser failures for decoded frontend APIs", () => { + const frontendApi = "[invalid-host"; + const error = captureError(() => + clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey(frontendApi)), + ); + + expect(error).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(error).toMatchObject({ + keyPrefix: "pk_test", + frontendApi, + reason: "invalid-url", + }); + expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); }); it("allows standard Clerk hosts and an exact configured custom hostname", () => { diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index bf5fb61ee3b..a384db77d8a 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -1,14 +1,84 @@ -export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { +import * as Schema from "effect/Schema"; + +const ClerkPublishableKeyPrefix = Schema.Literals(["pk_test", "pk_live", "unknown"]); + +export class ClerkPublishableKeyDecodeError extends Schema.TaggedErrorClass()( + "ClerkPublishableKeyDecodeError", + { + keyPrefix: ClerkPublishableKeyPrefix, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode Clerk publishable key (${this.keyPrefix}).`; + } +} + +export class ClerkPublishableKeyFrontendApiError extends Schema.TaggedErrorClass()( + "ClerkPublishableKeyFrontendApiError", + { + keyPrefix: ClerkPublishableKeyPrefix, + frontendApi: Schema.String, + reason: Schema.Literals(["empty", "contains-path", "invalid-url"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid Clerk frontend API decoded from publishable key (${this.keyPrefix}; ${this.reason}).`; + } +} + +function parseClerkFrontendApi(publishableKey: string): { + readonly hostname: string; + readonly url: string; +} { + const keyPrefix = publishableKey.startsWith("pk_test_") + ? "pk_test" + : publishableKey.startsWith("pk_live_") + ? "pk_live" + : "unknown"; const encodedFrontendApi = publishableKey.split("_").slice(2).join("_"); - const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); - if (frontendApi.length === 0 || frontendApi.includes("/")) { - throw new Error("Invalid Clerk publishable key."); + let frontendApi: string; + try { + frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); + } catch (cause) { + throw new ClerkPublishableKeyDecodeError({ keyPrefix, cause }); } - return `https://${frontendApi}`; + + if (frontendApi.length === 0) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "empty", + }); + } + if (frontendApi.includes("/")) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "contains-path", + }); + } + + const url = `https://${frontendApi}`; + try { + return { hostname: new URL(url).hostname, url }; + } catch (cause) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "invalid-url", + cause, + }); + } +} + +export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { + return parseClerkFrontendApi(publishableKey).url; } export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string { - return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname; + return parseClerkFrontendApi(publishableKey).hostname; } export function isAllowedClerkFrontendApiHostname( diff --git a/packages/shared/src/relayJwt.test.ts b/packages/shared/src/relayJwt.test.ts new file mode 100644 index 00000000000..4e863af484e --- /dev/null +++ b/packages/shared/src/relayJwt.test.ts @@ -0,0 +1,58 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { RelayJwtError, signRelayJwt, verifyRelayJwt } from "./relayJwt.ts"; + +describe("relayJwt", () => { + it.effect("preserves signing context and the JOSE cause", () => + Effect.gen(function* () { + const error = yield* signRelayJwt({ + privateKey: "not-a-private-key", + typ: "test-sign+jwt", + payload: { sub: "subject" }, + }).pipe(Effect.flip); + + expect(error.operation).toBe("sign"); + expect(error.typ).toBe("test-sign+jwt"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).toBe('Failed to sign relay JWT of type "test-sign+jwt".'); + }), + ); + + it.effect("preserves verification request context and the JOSE cause", () => + Effect.gen(function* () { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + publicKeyEncoding: { format: "pem", type: "spki" }, + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + }); + const error = yield* verifyRelayJwt({ + publicKey: keyPair.publicKey, + token: "not-a-jwt", + typ: "test-verify+jwt", + issuer: "https://issuer.example.test", + audience: "test-audience", + nowEpochSeconds: 100, + }).pipe(Effect.flip); + + expect(error.operation).toBe("verify"); + expect(error.typ).toBe("test-verify+jwt"); + expect(error.issuer).toBe("https://issuer.example.test"); + expect(error.audience).toBe("test-audience"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).toBe('Failed to verify relay JWT of type "test-verify+jwt".'); + }), + ); + + it("extracts stable diagnostic codes without copying cause text into the error message", () => { + const error = new RelayJwtError({ + operation: "verify", + typ: "test+jwt", + cause: { code: "ERR_JWT_EXPIRED", message: "sensitive library detail" }, + }); + + expect(RelayJwtError.diagnosticCode(error)).toBe("ERR_JWT_EXPIRED"); + expect(error.message).not.toContain("sensitive library detail"); + }); +}); diff --git a/packages/shared/src/relayJwt.ts b/packages/shared/src/relayJwt.ts index bd00023e8fb..9e848bedfb0 100644 --- a/packages/shared/src/relayJwt.ts +++ b/packages/shared/src/relayJwt.ts @@ -1,7 +1,8 @@ import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt"; export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt"; @@ -10,9 +11,30 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt"; export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt"; export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt"; -export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{ - readonly cause: unknown; -}> {} +export class RelayJwtError extends Schema.TaggedErrorClass()("RelayJwtError", { + operation: Schema.Literals(["sign", "verify"]), + typ: Schema.String, + issuer: Schema.optional(Schema.String), + audience: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to ${this.operation} relay JWT of type "${this.typ}".`; + } + + static diagnosticCode(error: RelayJwtError): string { + if ( + Predicate.isObject(error.cause) && + Predicate.hasProperty(error.cause, "code") && + Predicate.isString(error.cause.code) && + error.cause.code.length > 0 + ) { + return error.cause.code; + } + + return error.cause instanceof Error && error.cause.name ? error.cause.name : "unknown"; + } +} export function normalizeRelayIssuer(value: string): string { return value.trim().replace(/\/+$/gu, ""); @@ -38,7 +60,7 @@ export function signRelayJwt(input: { .setProtectedHeader({ alg: "EdDSA", typ: input.typ }) .sign(key); }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => new RelayJwtError({ operation: "sign", typ: input.typ, cause }), }); } @@ -49,6 +71,7 @@ export function verifyRelayJwt(input: { readonly issuer: string; readonly audience: string; readonly nowEpochSeconds: number; + readonly maxTokenAge?: string | number; }): Effect.Effect { return Effect.tryPromise({ try: async () => { @@ -58,12 +81,19 @@ export function verifyRelayJwt(input: { typ: input.typ, issuer: input.issuer, audience: input.audience, - maxTokenAge: "5 minutes", + maxTokenAge: input.maxTokenAge ?? "5 minutes", clockTolerance: 60, currentDate: DateTime.toDate(DateTime.makeUnsafe(input.nowEpochSeconds * 1_000)), }); return verified.payload; }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => + new RelayJwtError({ + operation: "verify", + typ: input.typ, + issuer: input.issuer, + audience: input.audience, + cause, + }), }); } diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts index 10f4e1087a3..3bb7f1ea1ac 100644 --- a/packages/shared/src/relayTracing.test.ts +++ b/packages/shared/src/relayTracing.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Tracer from "effect/Tracer"; +import { FetchHttpClient } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; -import { RelayClientTracer, withRelayClientTracing } from "./relayTracing.ts"; +import { + makeRelayClientTracingLayer, + RelayClientTracer, + withRelayClientTracing, +} from "./relayTracing.ts"; function collectingTracer(spans: Array): Tracer.Tracer { return Tracer.make({ @@ -54,4 +61,44 @@ describe("withRelayClientTracing", () => { expect(userSpans).toEqual(["relay.operation"]); }), ); + + it.effect("preserves nested error causes in exported relay spans", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const httpClientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)), + ); + const tracingLayer = makeRelayClientTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "relay-traces", + tracesToken: "public-ingest-token", + }, + { + serviceName: "relay-test", + runtime: "test", + client: "test", + }, + ).pipe(Layer.provide(httpClientLayer)); + const rootCause = new Error("relay socket closed"); + const failure = new Error("relay request failed", { cause: rootCause }); + const tracedApplication = Layer.effectDiscard( + Effect.fail(failure).pipe( + Effect.withSpan("relay.failed-operation"), + withRelayClientTracing, + Effect.exit, + ), + ).pipe(Layer.provide(tracingLayer)); + + return Layer.build(tracedApplication).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + const payload = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); + expect(payload).toContain("relay request failed"); + expect(payload).toContain("relay socket closed"); + }), + ), + ); + }); }); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 6005856d9d5..1259984ea3c 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -1,5 +1,7 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Tracer from "effect/Tracer"; @@ -39,6 +41,89 @@ export const withRelayClientTracing = ( ), ); +function cleanTraceStack(error: Error): string { + const stack = error.stack ?? `${error.name}: ${error.message}`; + const lines = stack.split("\n"); + const effectFrameIndex = lines.findIndex( + (line, index) => index > 0 && /(?:Generator\.next|~effect\/Effect)/.test(line), + ); + return effectFrameIndex < 0 ? stack : lines.slice(0, effectFrameIndex).join("\n"); +} + +function traceSafeError(value: unknown, seen = new WeakSet()): Error { + const message = + value instanceof Error + ? value.message + : typeof value === "object" && + value !== null && + "message" in value && + typeof value.message === "string" + ? value.message + : String(value); + + let cause: Error | undefined; + if (typeof value === "object" && value !== null && !seen.has(value)) { + seen.add(value); + if ("cause" in value && value.cause !== undefined) { + cause = traceSafeError(value.cause, seen); + } + } + + const error = new Error(message, cause ? { cause } : undefined); + if (value instanceof Error) { + error.name = value.name; + error.stack = cleanTraceStack(value); + } else if ( + typeof value === "object" && + value !== null && + "name" in value && + typeof value.name === "string" + ) { + error.name = value.name; + } + if (cause) { + error.stack = `${error.stack ?? `${error.name}: ${error.message}`}\nCaused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`; + } + return error; +} + +function traceSafeExit(exit: Exit.Exit): Exit.Exit { + if (Exit.isSuccess(exit)) { + return exit; + } + return Exit.failCause( + Cause.fromReasons( + exit.cause.reasons.map((reason) => { + if (Cause.isFailReason(reason)) { + return Cause.makeFailReason(traceSafeError(reason.error)); + } + if (Cause.isDieReason(reason)) { + return Cause.makeDieReason(traceSafeError(reason.defect)); + } + return reason; + }), + ), + ); +} + +function nonInterferingTracer(delegate: Tracer.Tracer): Tracer.Tracer { + return Tracer.make({ + span(options) { + const span = delegate.span(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + try { + end(endTime, traceSafeExit(exit)); + } catch { + // Telemetry is best-effort and must never change application behavior. + } + }; + return span; + }, + ...(delegate.context ? { context: delegate.context } : {}), + }); +} + export function makeRelayClientTracingLayer( config: RelayClientTracingConfig | null, resource: RelayClientTracingResource, @@ -64,7 +149,8 @@ export function makeRelayClientTracingLayer( }, }).pipe(Layer.provide(OtlpSerialization.layerJson)); - return Layer.effect(RelayClientTracer, Tracer.Tracer.pipe(Effect.map(Option.some))).pipe( - Layer.provide(tracerLayer), - ); + return Layer.effect( + RelayClientTracer, + Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.map(Option.some)), + ).pipe(Layer.provide(tracerLayer)); } diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 5ed058b9dc5..24e78757009 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveRemotePairingTarget } from "./remote.ts"; +import { + RemoteBackendUrlInvalidError, + RemoteBackendUrlMissingError, + RemotePairingTokenMissingError, + RemotePairingUrlInvalidError, + resolveRemotePairingTarget, +} from "./remote.ts"; describe("remote", () => { it("derives backend urls and token from a pairing url", () => { @@ -65,4 +71,89 @@ describe("remote", () => { wsBaseUrl: "wss://myserver.com:3000/", }); }); + + it("rejects unsupported direct pairing URL protocols", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: "ftp://remote.example.com/pair#token=pairing-token", + }); + } catch (cause) { + pairingUrlError = cause; + } + + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect(pairingUrlError).toMatchObject({ protocol: "ftp:" }); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported hosted pairing backend protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: + "https://app.t3.codes/pair?host=ftp%3A%2F%2Fremote.example.com#token=pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "hosted-pairing-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported direct host protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + host: "ftp://remote.example.com", + pairingCode: "pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + + it("uses distinct structural errors for missing pairing inputs", () => { + expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError); + expect(() => + resolveRemotePairingTarget({ pairingUrl: "https://remote.example.com/pair" }), + ).toThrowError(RemotePairingTokenMissingError); + expect(() => + resolveRemotePairingTarget({ + host: "https://user:secret@remote.example.com/path?token=sensitive#fragment", + }), + ).toThrowError( + expect.objectContaining({ + _tag: "RemotePairingCodeMissingError", + host: "remote.example.com", + }), + ); + }); + + it("preserves URL parsing causes with their input source", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ pairingUrl: "not a url" }); + } catch (cause) { + pairingUrlError = cause; + } + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeInstanceOf(TypeError); + + let hostError: unknown; + try { + resolveRemotePairingTarget({ host: "https://[invalid", pairingCode: "pairing-token" }); + } catch (cause) { + hostError = cause; + } + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeInstanceOf(TypeError); + }); }); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 8776b3f146b..4c2ae982631 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -1,25 +1,105 @@ import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { normalizeBasePath } from "./basePath.ts"; const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; +const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); -const normalizeRemoteBaseUrl = (rawValue: string): URL => { +export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( + "RemoteBackendUrlMissingError", + {}, +) { + override get message(): string { + return "Enter a backend URL."; + } +} + +export class RemotePairingUrlInvalidError extends Schema.TaggedErrorClass()( + "RemotePairingUrlInvalidError", + { + cause: Schema.optional(Schema.Defect()), + protocol: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return "Pairing URL is invalid."; + } +} + +export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass()( + "RemoteBackendUrlInvalidError", + { + source: Schema.Literals(["direct-host", "hosted-pairing-host"]), + cause: Schema.optional(Schema.Defect()), + protocol: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return "Backend URL is invalid."; + } +} + +export class RemotePairingTokenMissingError extends Schema.TaggedErrorClass()( + "RemotePairingTokenMissingError", + { host: Schema.String }, +) { + override get message(): string { + return "Pairing URL is missing its token."; + } +} + +export class RemotePairingCodeMissingError extends Schema.TaggedErrorClass()( + "RemotePairingCodeMissingError", + { host: Schema.String }, +) { + override get message(): string { + return "Enter a pairing code."; + } +} + +export const RemotePairingTargetError = Schema.Union([ + RemoteBackendUrlMissingError, + RemotePairingUrlInvalidError, + RemoteBackendUrlInvalidError, + RemotePairingTokenMissingError, + RemotePairingCodeMissingError, +]); +export type RemotePairingTargetError = typeof RemotePairingTargetError.Type; + +const hasSupportedRemoteBackendProtocol = (url: URL): boolean => + SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol); + +const normalizeRemoteBaseUrl = ( + rawValue: string, + source: RemoteBackendUrlInvalidError["source"], +): URL => { const trimmed = rawValue.trim(); if (!trimmed) { - throw new Error("Enter a backend URL."); + throw new RemoteBackendUrlMissingError(); } const normalizedInput = /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") ? trimmed : `https://${trimmed}`; - const url = new URL(normalizedInput); + let url: URL; + try { + url = new URL(normalizedInput); + } catch (cause) { + throw new RemoteBackendUrlInvalidError({ source, cause }); + } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemoteBackendUrlInvalidError({ + source, + protocol: url.protocol, + }); + } url.search = ""; url.hash = ""; return url; @@ -109,10 +189,23 @@ export const resolveRemotePairingTarget = (input: { }): ResolvedRemotePairingTarget => { const pairingUrl = input.pairingUrl?.trim() ?? ""; if (pairingUrl.length > 0) { - const url = new URL(pairingUrl); + let url: URL; + try { + url = new URL(pairingUrl); + } catch (cause) { + throw new RemotePairingUrlInvalidError({ cause }); + } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemotePairingUrlInvalidError({ + protocol: url.protocol, + }); + } const hostedPairingRequest = readHostedPairingRequest(url); if (hostedPairingRequest) { - const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host); + const hostedBackendUrl = normalizeRemoteBaseUrl( + hostedPairingRequest.host, + "hosted-pairing-host", + ); return { credential: hostedPairingRequest.token, httpBaseUrl: toHttpBaseUrl(hostedBackendUrl), @@ -122,7 +215,7 @@ export const resolveRemotePairingTarget = (input: { const credential = getPairingTokenFromUrl(url) ?? ""; if (!credential) { - throw new Error("Pairing URL is missing its token."); + throw new RemotePairingTokenMissingError({ host: url.host }); } const httpBaseUrl = toHttpBaseUrlFromPairingUrl(url); return { @@ -135,13 +228,13 @@ export const resolveRemotePairingTarget = (input: { const host = input.host?.trim() ?? ""; const pairingCode = input.pairingCode?.trim() ?? ""; if (!host) { - throw new Error("Enter a backend URL."); + throw new RemoteBackendUrlMissingError(); } + const normalizedHost = normalizeRemoteBaseUrl(host, "direct-host"); if (!pairingCode) { - throw new Error("Enter a pairing code."); + throw new RemotePairingCodeMissingError({ host: normalizedHost.host }); } - const normalizedHost = normalizeRemoteBaseUrl(host); return { credential: pairingCode, httpBaseUrl: toHttpBaseUrl(normalizedHost), diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index 1cc6e38d919..c808a9b7c51 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -1,7 +1,15 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { extractJsonObject, fromLenientJson } from "./schemaJson.ts"; +import { + decodeJsonResult, + extractJsonObject, + formatSchemaError, + fromLenientJson, +} from "./schemaJson.ts"; const decodeLenientJson = Schema.decodeUnknownSync(fromLenientJson(Schema.Unknown)); @@ -48,4 +56,90 @@ Done.`), it("rejects malformed JSON after lenient preprocessing", () => { expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow(); }); + + it("formats schema failures with paths without exposing invalid values", () => { + const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number })); + const decoded = decodeCredential('{"token":"credential=secret-value"}'); + + expect(Result.isFailure(decoded)).toBe(true); + if (Result.isFailure(decoded)) { + expect(formatSchemaError(decoded.failure)).toBe('Invalid type\n at ["token"]'); + } + }); + + it("preserves nested paths reported by schema filters", () => { + const decode = decodeJsonResult( + Schema.String.check( + Schema.makeFilter(() => ({ + path: ["session", "token"], + issue: "credential is invalid", + })), + ), + ); + const decoded = decode('"credential=secret-value"'); + + expect(Result.isFailure(decoded)).toBe(true); + if (Result.isFailure(decoded)) { + const diagnostic = formatSchemaError(decoded.failure); + expect(diagnostic).toBe('Invalid value\n at ["session"]["token"]'); + expect(diagnostic).not.toContain("credential=secret-value"); + } + }); + + it("does not expose malformed lenient JSON input in diagnostics", () => { + const decode = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown)); + const exit = decode('{"token":"credential=secret-value",,}'); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const diagnostic = formatSchemaError(exit.cause); + expect(diagnostic).toBe("Invalid value"); + expect(diagnostic).not.toContain("credential=secret-value"); + } + }); + + it("summarizes unexpected defects without serializing their messages", () => { + const diagnostic = formatSchemaError(Cause.die(new Error("credential=secret-value"))); + + expect(diagnostic).toBe( + "Schema validation failed (failureCount=0, defectCount=1, interruptionCount=0).", + ); + }); + + it("bounds the number of formatted schema issues", () => { + const decode = decodeJsonResult(Schema.Struct({ token: Schema.Number })); + const failures: Array> = []; + for (let index = 0; index < 10; index += 1) { + const decoded = decode(`{"token":"credential=secret-value-${index}"}`); + if (Result.isFailure(decoded)) { + failures.push(decoded.failure); + } + } + + const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons)); + const diagnostic = formatSchemaError(cause); + expect(diagnostic.match(/Invalid type/g)).toHaveLength(8); + expect(diagnostic).toContain("... and 2 more issue(s)"); + }); + + it("retains the omitted issue count when bounding long diagnostics", () => { + const longPath = Array.from({ length: 16 }, (_, index) => `${index}-${"segment".repeat(16)}`); + const decode = decodeJsonResult( + Schema.String.check( + Schema.makeFilter(() => ({ path: longPath, issue: "credential is invalid" })), + ), + ); + const failures: Array> = []; + for (let index = 0; index < 10; index += 1) { + const decoded = decode(`"credential=secret-value-${index}"`); + if (Result.isFailure(decoded)) { + failures.push(decoded.failure); + } + } + + const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons)); + const diagnostic = formatSchemaError(cause); + expect(diagnostic.length).toBeLessThanOrEqual(2_048); + expect(diagnostic.endsWith("\n... and 2 more issue(s)")).toBe(true); + }); }); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 8b76d9e0a2d..04d26d9c229 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -8,6 +8,104 @@ import * as SchemaGetter from "effect/SchemaGetter"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; +const MAX_SCHEMA_DIAGNOSTIC_ISSUES = 8; +const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS = 16; +const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH = 64; +const MAX_SCHEMA_DIAGNOSTIC_LENGTH = 2_048; + +interface SchemaDiagnosticIssue { + readonly message: string; + readonly path: ReadonlyArray; +} + +// Schema's default formatter includes actual values. These diagnostics cross +// process and UI boundaries, so retain only issue kinds and bounded paths. + +function truncateDiagnostic(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; +} + +function formatDiagnosticPathSegment(key: PropertyKey): string { + if (typeof key === "number") { + return `[${key}]`; + } + const value = truncateDiagnostic( + typeof key === "symbol" ? String(key) : key, + MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH, + ); + return `[${JSON.stringify(value)}]`; +} + +function formatDiagnosticIssue(issue: SchemaDiagnosticIssue): string { + if (issue.path.length === 0) { + return issue.message; + } + const path = issue.path + .slice(0, MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS) + .map(formatDiagnosticPathSegment) + .join(""); + const suffix = issue.path.length > MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS ? "[...]" : ""; + return `${issue.message}\n at ${path}${suffix}`; +} + +function schemaDiagnosticMessage(issue: SchemaIssue.Issue): string { + switch (issue._tag) { + case "InvalidType": + return "Invalid type"; + case "InvalidValue": + case "Filter": + case "AnyOf": + case "Encoding": + case "Pointer": + case "Composite": + return "Invalid value"; + case "MissingKey": + return "Missing key"; + case "UnexpectedKey": + return "Unexpected key"; + case "Forbidden": + return "Forbidden operation"; + case "OneOf": + return "Expected exactly one schema member to match"; + } +} + +function collectSchemaDiagnosticIssues( + issue: SchemaIssue.Issue, + path: ReadonlyArray, + diagnostics: Array, +): number { + switch (issue._tag) { + case "Encoding": + return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics); + case "Filter": + if (issue.issue._tag !== "InvalidValue") { + return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics); + } + break; + case "Pointer": + return collectSchemaDiagnosticIssues(issue.issue, [...path, ...issue.path], diagnostics); + case "Composite": + return issue.issues.reduce( + (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics), + 0, + ); + case "AnyOf": + if (issue.issues.length > 0) { + return issue.issues.reduce( + (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics), + 0, + ); + } + break; + } + + if (diagnostics.length < MAX_SCHEMA_DIAGNOSTIC_ISSUES) { + diagnostics.push({ message: schemaDiagnosticMessage(issue), path }); + } + return 1; +} + export const decodeJsonResult = >( schema: S, ) => { @@ -35,10 +133,40 @@ export const decodeUnknownJsonResult = ) => { - const squashed = Cause.squash(cause); - return Schema.isSchemaError(squashed) - ? SchemaIssue.makeFormatterDefault()(squashed.issue) - : Cause.pretty(cause); + const issues: Array = []; + let issueCount = 0; + let failureCount = 0; + let defectCount = 0; + let interruptionCount = 0; + + for (const reason of cause.reasons) { + switch (reason._tag) { + case "Fail": + failureCount += 1; + if (Schema.isSchemaError(reason.error)) { + issueCount += collectSchemaDiagnosticIssues(reason.error.issue, [], issues); + } + break; + case "Die": + defectCount += 1; + break; + case "Interrupt": + interruptionCount += 1; + break; + } + } + + if (issues.length === 0) { + return `Schema validation failed (failureCount=${failureCount}, defectCount=${defectCount}, interruptionCount=${interruptionCount}).`; + } + + const omittedIssueCount = issueCount - issues.length; + const formatted = issues.map(formatDiagnosticIssue).join("\n"); + if (omittedIssueCount === 0) { + return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH); + } + const suffix = `\n... and ${omittedIssueCount} more issue(s)`; + return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH - suffix.length) + suffix; }; /** @@ -67,9 +195,7 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { return decodeJsonString(stripped).pipe( Effect.map(Option.some), - Effect.mapError( - (error) => new SchemaIssue.InvalidValue(Option.some(input), { message: String(error) }), - ), + Effect.mapError((error) => error.issue), ); }); diff --git a/packages/shared/src/schemaYaml.test.ts b/packages/shared/src/schemaYaml.test.ts index 7b5a23acbe1..c4eaab88b02 100644 --- a/packages/shared/src/schemaYaml.test.ts +++ b/packages/shared/src/schemaYaml.test.ts @@ -50,9 +50,45 @@ tags: expect(decodeYaml("answer: 42\n")).toEqual({ answer: 42 }); }); - it("rejects malformed YAML", () => { + it("reports malformed YAML with safe structural diagnostics", () => { const decodeYaml = Schema.decodeUnknownSync(fromYaml(Schema.Unknown)); + const secret = "credential=secret-value"; + let error: unknown; - expect(() => decodeYaml("name: ok\n bad-indent: nope\n")).toThrow(); + try { + decodeYaml(`name: ${secret}\n bad-indent: nope\n`); + } catch (cause) { + error = cause; + } + + expect(Schema.isSchemaError(error)).toBe(true); + if (!Schema.isSchemaError(error)) { + throw new Error("Expected a schema error"); + } + expect(error.message).toBe("Invalid YAML (code=BLOCK_AS_IMPLICIT_KEY, line=1, column=7)."); + expect(error.message).not.toContain(secret); + }); + + it("does not expose stringify failure details", () => { + const encodeYaml = Schema.encodeSync(fromYaml(Schema.Unknown)); + const secret = "credential=secret-value"; + let error: unknown; + + try { + encodeYaml({ + toJSON() { + throw new Error(secret); + }, + }); + } catch (cause) { + error = cause; + } + + expect(Schema.isSchemaError(error)).toBe(true); + if (!Schema.isSchemaError(error)) { + throw new Error("Expected a schema error"); + } + expect(error.message).toBe("Failed to stringify YAML."); + expect(error.message).not.toContain(secret); }); }); diff --git a/packages/shared/src/schemaYaml.ts b/packages/shared/src/schemaYaml.ts index 1b0d10fb888..ddb1b5c916c 100644 --- a/packages/shared/src/schemaYaml.ts +++ b/packages/shared/src/schemaYaml.ts @@ -5,6 +5,7 @@ import * as SchemaGetter from "effect/SchemaGetter"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { + YAMLParseError, parse as parseYamlString, stringify as stringifyYamlValue, type CreateNodeOptions, @@ -22,8 +23,14 @@ export type YamlStringifyOptions = DocumentOptions & CreateNodeOptions & ToStringOptions; -function formatYamlError(error: unknown): string { - return error instanceof Error ? error.message : String(error); +function formatYamlParseError(error: unknown): string { + if (!(error instanceof YAMLParseError)) { + return "Invalid YAML."; + } + + const position = error.linePos?.[0]; + const location = position === undefined ? "" : `, line=${position.line}, column=${position.col}`; + return `Invalid YAML (code=${error.code}${location}).`; } /** @@ -56,7 +63,7 @@ export function parseYaml( Effect.try({ try: () => parseYamlString(input, options) as unknown, catch: (error) => - new SchemaIssue.InvalidValue(Option.some(input), { message: formatYamlError(error) }), + new SchemaIssue.InvalidValue(Option.none(), { message: formatYamlParseError(error) }), }), ); } @@ -90,8 +97,8 @@ export function stringifyYaml( return SchemaGetter.transformOrFail((input: unknown) => Effect.try({ try: () => stringifyYamlValue(input, options), - catch: (error) => - new SchemaIssue.InvalidValue(Option.some(input), { message: formatYamlError(error) }), + catch: () => + new SchemaIssue.InvalidValue(Option.none(), { message: "Failed to stringify YAML." }), }), ); } diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 5eab78b83d5..cf2f2417ff4 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { execFileSync } from "node:child_process"; -import { accessSync, constants as fileSystemConstants, statSync } from "node:fs"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -26,7 +26,7 @@ type ExecFileSyncLike = ( function canExecuteFile(filePath: string): boolean { try { - accessSync(filePath, fileSystemConstants.X_OK); + NodeFS.accessSync(filePath, NodeFS.constants.X_OK); return true; } catch { return false; @@ -108,7 +108,7 @@ function resolveSpawnExecutableWithNode( ); const isExecutable = (candidate: string) => { try { - if (!statSync(candidate).isFile()) return false; + if (!NodeFS.statSync(candidate).isFile()) return false; if (platform === "win32") { return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); } @@ -192,13 +192,13 @@ export function extractPathFromShellOutput(output: string): string | null { export function readPathFromLoginShell( shell: string, - execFile: ExecFileSyncLike = execFileSync, + execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, ): string | undefined { return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; } export function readPathFromLaunchctl( - execFile: ExecFileSyncLike = execFileSync, + execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, ): string | undefined { try { return trimNonEmpty( @@ -305,7 +305,7 @@ export type ShellEnvironmentReader = ( export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( shell, names, - execFile = execFileSync, + execFile = NodeChildProcess.execFileSync, ) => { if (names.length === 0) { return {}; @@ -371,7 +371,7 @@ export function readEnvironmentFromWindowsShell( const execFile: ExecFileSyncLike = typeof optionsOrExecFile === "function" ? optionsOrExecFile - : (maybeExecFile ?? (execFileSync as ExecFileSyncLike)); + : (maybeExecFile ?? (NodeChildProcess.execFileSync as ExecFileSyncLike)); const command = buildWindowsEnvironmentCaptureCommand(names); const args = [ "-NoLogo", diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index aa48a1b357e..10927b43089 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,4 +1,4 @@ -import * as Crypto from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -74,7 +74,10 @@ export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string } export function remoteStateKey(target: DesktopSshEnvironmentTarget): string { - return Crypto.createHash("sha256").update(targetConnectionKey(target)).digest("hex").slice(0, 16); + return NodeCrypto.createHash("sha256") + .update(targetConnectionKey(target)) + .digest("hex") + .slice(0, 16); } export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 2e5c1a69904..7e7a5a54276 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -105,7 +105,9 @@ describe("ssh tunnel scripts", () => { assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); - assert.include(script, 'eval "$(fnm env --use-on-cd --shell sh)"'); + assert.include(script, 'eval "$(fnm env --shell bash)"'); + assert.include(script, "fnm use --silent-if-unchanged"); + assert.include(script, "fnm use default"); assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); assert.include(script, 'NVM_DIR="$HOME/.nvm"'); assert.include(script, "nvm use --silent default"); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index a7f0d68c2a3..e8c2b924759 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -398,7 +398,8 @@ ensure_remote_node_path() { prepend_path_if_dir "$FNM_DIR" prepend_path_if_dir "$HOME/.fnm" if ! command -v node >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then - eval "$(fnm env --use-on-cd --shell sh)" >/dev/null 2>&1 || eval "$(fnm env --shell sh)" >/dev/null 2>&1 || true + eval "$(fnm env --shell bash)" >/dev/null 2>&1 || true + fnm use --silent-if-unchanged >/dev/null 2>&1 || fnm use default >/dev/null 2>&1 || true fi prepend_path_if_dir "$HOME/.nodenv/bin" diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index dd2b1772fd6..853bb1f81c2 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,8 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -13,6 +17,11 @@ import { parseTailscaleMagicDnsName, parseTailscaleStatus, readTailscaleStatus, + TAILSCALE_STATUS_TIMEOUT, + TailscaleCommandExitError, + TailscaleCommandSpawnError, + TailscaleCommandTimeoutError, + TailscaleStatusParseError, } from "./tailscale.ts"; const encoder = new TextEncoder(); @@ -35,6 +44,22 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) }); } +function neverFinishingMockHandle() { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + function mockSpawnerLayer( handler: ( command: string, @@ -81,6 +106,17 @@ describe("tailscale", () => { }), ); + it.effect("preserves status decoding failures without exposing cause text", () => + Effect.gen(function* () { + const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); + + assert.instanceOf(error, TailscaleStatusParseError); + assert.equal(error.message, "Failed to decode tailscale status JSON."); + assert.isDefined(error.cause); + assert.notInclude(error.message, String(error.cause)); + }), + ); + it.effect("builds clean HTTPS base URLs", () => Effect.sync(() => { assert.equal( @@ -112,6 +148,80 @@ describe("tailscale", () => { }); }); + it.effect("preserves tailscale spawn failures as causes", () => { + const systemCause = new Error("private executable lookup detail"); + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: systemCause, + }); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandSpawnError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to spawn tailscale status."); + assert.notInclude(error.message, systemCause.message); + }); + }); + + it.effect("keeps nonzero exit diagnostics structured", () => { + const layer = mockSpawnerLayer(() => ({ + code: 7, + stderr: "not logged in tskey-auth-secret-token-value", + })); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.equal(error.exitCode, 7); + assert.equal(error.stdoutLength, 0); + assert.equal(error.stderrLength, 43); + assert.notProperty(error, "command"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + assert.equal(error.message, "tailscale status exited with code 7."); + }); + }); + + it.effect("times out tailscale status through TestClock", () => { + const layer = Layer.merge( + TestClock.layer(), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(neverFinishingMockHandle())), + ), + ); + + return Effect.gen(function* () { + const fiber = yield* readTailscaleStatus.pipe(Effect.flip, Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT); + const error = yield* Fiber.join(fiber); + + assert.instanceOf(error, TailscaleCommandTimeoutError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.equal(error.timeoutMs, 1_500); + assert.isTrue(Cause.isTimeoutError(error.cause)); + assert.equal(error.message, "tailscale status timed out after 1500ms."); + }).pipe(Effect.provide(layer)); + }); + it.effect("configures tailscale serve through the process spawner service", () => { const layer = mockSpawnerLayer((command, args) => { assert.equal(command, "tailscale"); @@ -122,6 +232,30 @@ describe("tailscale", () => { return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer)); }); + it.effect("retains tailscale serve exit diagnostics", () => { + const layer = mockSpawnerLayer(() => ({ + code: 1, + stderr: "serve permission denied tskey-auth-secret-token-value", + })); + + return Effect.gen(function* () { + const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "serve"); + assert.equal(error.argumentCount, 4); + assert.equal(error.exitCode, 1); + assert.equal(error.stderrLength, 53); + assert.notProperty(error, "command"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + }); + }); + it.effect("disables tailscale serve through the process spawner service", () => { const commands: { readonly command: string; diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index dba880988e4..fe8e4d25a14 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,5 +1,5 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -9,29 +9,88 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; -export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; -export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; -export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; +export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); +export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); +export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. -const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => +const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => platform === "win32" ? "tailscale.exe" : "tailscale"; -export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ - readonly command: readonly string[]; - readonly message: string; - readonly exitCode: number | null; - readonly stderr: string; -}> {} +const TailscaleCommandContext = { + executable: Schema.Literals(["tailscale", "tailscale.exe"]), + subcommand: Schema.Literals(["status", "serve"]), + argumentCount: Schema.Number, +}; + +export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( + "TailscaleCommandSpawnError", + { + ...TailscaleCommandContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn tailscale ${this.subcommand}.`; + } +} + +export class TailscaleCommandOutputError extends Schema.TaggedErrorClass()( + "TailscaleCommandOutputError", + { + ...TailscaleCommandContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read output from tailscale ${this.subcommand}.`; + } +} + +export class TailscaleCommandExitError extends Schema.TaggedErrorClass()( + "TailscaleCommandExitError", + { + ...TailscaleCommandContext, + exitCode: Schema.Number, + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `tailscale ${this.subcommand} exited with code ${this.exitCode}.`; + } +} + +export class TailscaleCommandTimeoutError extends Schema.TaggedErrorClass()( + "TailscaleCommandTimeoutError", + { + ...TailscaleCommandContext, + timeoutMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `tailscale ${this.subcommand} timed out after ${this.timeoutMs}ms.`; + } +} -export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{ - readonly cause: unknown; -}> {} +export const TailscaleCommandError = Schema.Union([ + TailscaleCommandSpawnError, + TailscaleCommandOutputError, + TailscaleCommandExitError, + TailscaleCommandTimeoutError, +]); +export type TailscaleCommandError = typeof TailscaleCommandError.Type; -export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{ - readonly reason: string; -}> {} +export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( + "TailscaleStatusParseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to decode tailscale status JSON."; + } +} const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), @@ -61,19 +120,6 @@ const collectStdout = (stream: Stream.Stream): Effect.Effect - new TailscaleCommandError({ - command: ["tailscale", ...args], - message, - exitCode, - stderr, - }); - const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { @@ -135,63 +181,56 @@ export const parseTailscaleStatus = ( }), ); -export const readTailscaleStatus: Effect.Effect< - TailscaleStatus, - TailscaleCommandError | TailscaleStatusParseError, - ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { +export const readTailscaleStatus = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const child = yield* spawner - .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) - .pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : "Failed to spawn tailscale status.", - null, - ), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStdout(child.stdout), - collectStderr(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : "Failed to run tailscale status.", - null, - ), - ), - ); - if (exitCode !== 0) { - return yield* tailscaleCommandError( - args, - `Tailscale status exited with code ${exitCode}.`, - exitCode, - stderr, + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: "status" as const, + argumentCount: args.length, + }; + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(executable, args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); - } - return yield* parseTailscaleStatus(stdout); -}).pipe( - Effect.scoped, - Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + return yield* parseTailscaleStatus(stdout); + }).pipe( + Effect.scoped, + Effect.timeout(TAILSCALE_STATUS_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail( - tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null), + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), + cause, + }), ), - onSome: Effect.succeed, }), - ), -); + ); +}); export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; @@ -209,53 +248,52 @@ export function buildTailscaleHttpsBaseUrl(input: { const runTailscaleCommand = ( args: readonly string[], - input: { - readonly spawnMessage: string; - readonly runMessage: string; - readonly exitMessage: (exitCode: number) => string; - readonly timeoutMessage: string; - readonly timeoutMs: number; - }, + timeoutInput: Duration.Input, ): Effect.Effect => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const child = yield* spawner - .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) - .pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : input.spawnMessage, - null, - ), - ), + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: "serve" as const, + argumentCount: args.length, + }; + const timeout = Duration.fromInputUnsafe(timeoutInput); + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(executable, args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); + const [stderr, exitCode] = yield* Effect.all( + [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); - const [stderr, exitCode] = yield* Effect.all( - [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : input.runMessage, - null, - ), - ), - ); - if (exitCode !== 0) { - return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr); - } - }).pipe( - Effect.scoped, - Effect.timeoutOption(input.timeoutMs), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), - onSome: Effect.succeed, + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stderrLength: stderr.length, + }); + } + }).pipe( + Effect.scoped, + Effect.timeout(timeout), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(timeout), + cause, + }), + ), }), - ), - ); + ); + }); export const ensureTailscaleServe = (input: { readonly localPort: number; @@ -265,13 +303,7 @@ export const ensureTailscaleServe = (input: { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; const localHost = input.localHost ?? "127.0.0.1"; const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; - return runTailscaleCommand(args, { - spawnMessage: "Failed to spawn tailscale serve.", - runMessage: "Failed to run tailscale serve.", - exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, - timeoutMessage: "Tailscale serve timed out.", - timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, - }); + return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); }; export const disableTailscaleServe = ( @@ -281,19 +313,16 @@ export const disableTailscaleServe = ( ): Effect.Effect => Effect.gen(function* () { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], { - spawnMessage: "Failed to spawn tailscale serve off.", - runMessage: "Failed to run tailscale serve off.", - exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, - timeoutMessage: "Tailscale serve off timed out.", - timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, - }); + return yield* runTailscaleCommand( + ["serve", `--https=${servePort}`, "off"], + TAILSCALE_SERVE_TIMEOUT, + ); }); export const probeTailscaleHttpsEndpoint = (input: { readonly baseUrl: string; readonly basePath?: NormalizedBasePath; - readonly timeoutMs?: number; + readonly timeout?: Duration.Input; }): Effect.Effect => Effect.gen(function* () { const client = yield* HttpClient.HttpClient; @@ -304,7 +333,7 @@ export const probeTailscaleHttpsEndpoint = (input: { url.hash = ""; const request = HttpClientRequest.get(url.toString()); return yield* client.execute(request); - }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS)); + }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT)); return Option.match(response, { onNone: () => false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce5f5ec66ed..813ae11a98e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,13 @@ catalogs: version: 0.1.24 overrides: + '@clerk/backend': 3.8.3-snapshot.v20260622234151 + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151 + '@clerk/electron': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 + '@clerk/expo': 3.5.3-snapshot.v20260622234151 + '@clerk/react': 6.11.0-snapshot.v20260622234151 + '@clerk/shared': 4.21.0-snapshot.v20260622234151 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' @@ -105,6 +112,12 @@ importers: apps/desktop: dependencies: + '@clerk/electron': + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/electron-passkeys': + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151 '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -129,6 +142,9 @@ importers: electron: specifier: 41.5.0 version: 41.5.0 + electron-store: + specifier: ^8.2.0 + version: 8.2.0 electron-updater: specifier: ^6.6.2 version: 6.8.3 @@ -180,8 +196,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: ^3.4.1 - version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 3.5.3-snapshot.v20260622234151 + version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -192,8 +208,8 @@ importers: specifier: ~56.0.8 version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@legendapp/list': - specifier: 3.0.0-beta.44 - version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 3.2.0 + version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -247,7 +263,7 @@ importers: version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ^56.0.0 - version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-asset: specifier: ~56.0.15 version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) @@ -290,6 +306,9 @@ importers: expo-linking: specifier: ~56.0.12 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-network: + specifier: ~56.0.5 + version: 56.0.5(expo@56.0.8)(react@19.2.3) expo-notifications: specifier: ~56.0.14 version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) @@ -336,13 +355,13 @@ importers: specifier: ^0.2.2 version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: - specifier: 1.21.6 - version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 1.21.7 + version: 1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: - specifier: ^0.35.4 + specifier: 0.35.9 version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1 @@ -359,6 +378,9 @@ importers: react-native-svg: specifier: 15.15.4 version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-webview: + specifier: ^13.16.1 + version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -460,12 +482,12 @@ importers: '@base-ui/react': specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/clerk-js': - specifier: ^6.16.0 - version: 6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/electron': + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: ^6.9.0 - version: 6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.11.0-snapshot.v20260622234151 + version: 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -491,8 +513,8 @@ importers: specifier: ^0.9.0 version: 0.9.0 '@legendapp/list': - specifier: 3.0.0-beta.44 - version: 3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.2.0 + version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -514,9 +536,6 @@ importers: '@tanstack/react-pacer': specifier: ^0.19.4 version: 0.19.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/react-query': - specifier: ^5.90.0 - version: 5.100.14(react@19.2.6) '@tanstack/react-router': specifier: ^1.160.2 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -605,9 +624,6 @@ importers: msw: specifier: 2.12.11 version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) - playwright: - specifier: ^1.58.2 - version: 1.60.0 tailwindcss: specifier: ^4.0.0 version: 4.3.0 @@ -617,15 +633,12 @@ importers: vite-plus: specifier: 'catalog:' version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - vitest-browser-react: - specifier: ^2.0.5 - version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) infra/relay: dependencies: '@clerk/backend': - specifier: 3.6.1 - version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.8.3-snapshot.v20260622234151 + version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) @@ -1519,19 +1532,60 @@ packages: resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.6.1': - resolution: {integrity: sha512-LkfekzF/0UMXacX+17xy3ExRraO0mm+thXejC8Q32gWHd1wLdxK3YXDsLDF00E1r1InWBKIt2ZOxs6hTwZPJjA==} + '@clerk/backend@3.8.3-snapshot.v20260622234151': + resolution: {integrity: sha512-B5goX0n/5pibc4dMQOfMmn4mPq7eqtrbNV20tOqO9qMPzFA+TKAj9SnB0oJvFsZAXKO6+/n16uYPbqeM2PN6CA==} + engines: {node: '>=20.9.0'} + + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151': + resolution: {integrity: sha512-mRNn6H8GbeEkcCIzZ03WZ9c1Uy8znf70okYmmeJKzK72gsdwnrxEfbu3DYE/5yRbX6lvL7ugWaNTT3DvPEbBzg==} + engines: {node: '>=20.9.0'} + + '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-NZjIVGitAf0yyQvs1WXIAYOle9RjGsExpfwje2U20POTnNueFy56M0g39UTFQXJ42saTZ0zauLD8sd0cHqpOjA==} + cpu: [arm64] + os: [darwin] + + '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-gGAwinVoIxa9lefCJjhf5D6oA8uq1sXQ/JwqffkBPJrvczYyAv9FYuQ1/ihji3jTplC0/bpTuIrwBkBCzwj5ug==} + cpu: [x64] + os: [darwin] + + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-anBFktMTAF7of37nLQsqQuW489w563J75l4QolWtblKbrBjlwFsEif95uj3vlQj0qWVVRWpAcxD21oD6wdJcwQ==} + cpu: [arm64] + os: [win32] + + '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-Pq4FOklTJNpyMTJpxY+/gq6CukvWHFJfGYjuz985cPHup1OSsVmuO/GYil36vh7Qbe8vDivT91wNe73DbEOcVw==} + cpu: [x64] + os: [win32] + + '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-UvBweTg9+FCbygxARV7RvJ4/lcQgLN+gJC6Lj++cLmbxpgRwRAgR9xXRlKV6dVg1p5k81a4DcFNMu/oR6zBdlQ==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.16.0': - resolution: {integrity: sha512-8xv/XDsxhOZd1n4DNIRJ2EehIRUg6UiqKAnfd0L88R2t1g6sVnLi1FInJ5i8Qyx5oY/creXx6X1AZ1V5PobRkA==} + '@clerk/electron@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-T0LUJeAPaAZxpk13Q14b9LSVKKio/X/zFo67hgdr35MaOChsn4T6lQ/rEL7laScQBduskcs1yyjr3e9ndy5ojg==} engines: {node: '>=20.9.0'} + peerDependencies: + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 + electron: '>=28' + electron-store: ^8.2.0 + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + peerDependenciesMeta: + '@clerk/electron-passkeys': + optional: true + electron-store: + optional: true + react-dom: + optional: true - '@clerk/expo@3.4.1': - resolution: {integrity: sha512-gpAXsuUnsUdUD0/2XjyxaC9quF5rT+2umkmV74nBLVAFurGMMMMHvnHqrQEtZ7tH5GHNXYw5+pgmnzd1HiMQbQ==} + '@clerk/expo@3.5.3-snapshot.v20260622234151': + resolution: {integrity: sha512-qeKTJYA7cTe5oCmRVCT4A2QEPRp2af0ox0VdXzIhWRqRVFNF5bnsZRNKE2e4QnsC16NYLsGHLR5uQGC15qBiug==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/expo-passkeys': '>=0.0.6' + '@clerk/expo-passkeys': 1.1.9-snapshot.v20260622234151 expo: '>=53 <57' expo-apple-authentication: '>=7.0.0' expo-auth-session: '>=5' @@ -1542,7 +1596,7 @@ packages: expo-web-browser: '>=12.5.0' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-native: '>=0.73' + react-native: '>=0.75' peerDependenciesMeta: '@clerk/expo-passkeys': optional: true @@ -1563,15 +1617,15 @@ packages: react-dom: optional: true - '@clerk/react@6.9.0': - resolution: {integrity: sha512-M0QGyGS732tYBXeG+28UgElXM2TfoSZ+4mWGisC8yxJX8NjH4hEPJTAQuZmYRLNaCyGQCuzjYVQiQRC+GbDtmA==} + '@clerk/react@6.11.0-snapshot.v20260622234151': + resolution: {integrity: sha512-yF4jQFJqEHAqZCpOtjQ/Kg9yqZlEG6vW2vmVR5kVwgESkpOR1KPJIEMU8o3b6W86RKQai4lt3CWtY+o7CJsDyw==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.17.0': - resolution: {integrity: sha512-YeQ+6zDmqyor1mPHjZx18j+LssL6Pobvid8hb7HQMioSo8sGDBEVi/Z12bs+gUhe9KbdP+ygHsKOqqeGAPuPZA==} + '@clerk/shared@4.21.0-snapshot.v20260622234151': + resolution: {integrity: sha512-OYu+hO0GHiHwYTwSFe/GCHmuQlyTHyNa7fJ8vrmtUyML09mf+dayRFeUnxgkwOYhGriyq40t1zxdVx53Td73xg==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -2691,8 +2745,8 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} - '@legendapp/list@3.0.0-beta.44': - resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==} + '@legendapp/list@3.2.0': + resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==} peerDependencies: react: '*' react-dom: '*' @@ -4406,11 +4460,6 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query@5.100.14': - resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} - peerDependencies: - react: ^18 || ^19 - '@tanstack/react-router@1.170.10': resolution: {integrity: sha512-gVmWYq0ucWr+OB97Nud0YhKa9NOipB7/QrWI7wRZJJWEL0qUS8WPqAs0vA1f3IBXZpXmf8xxzf/tl5cmo4tlmA==} engines: {node: '>=20.19'} @@ -4679,35 +4728,6 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - '@voidzero-dev/vite-plus-core@0.1.24': resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4999,6 +5019,14 @@ packages: ajv: optional: true + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -5164,6 +5192,10 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + auto-bind@5.0.1: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5372,10 +5404,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -5576,6 +5604,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + connect@3.7.0: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} @@ -5684,6 +5716,10 @@ packages: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -5823,6 +5859,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -5981,6 +6021,9 @@ packages: electron-publish@26.8.1: resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} + electron-store@8.2.0: + resolution: {integrity: sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==} + electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} @@ -6109,9 +6152,6 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -6131,10 +6171,6 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - expo-application@56.0.3: resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} peerDependencies: @@ -6286,6 +6322,12 @@ packages: peerDependencies: react-native: '*' + expo-network@56.0.5: + resolution: {integrity: sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==} + peerDependencies: + expo: '*' + react: '*' + expo-notifications@56.0.15: resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} peerDependencies: @@ -6530,6 +6572,10 @@ packages: find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -6586,11 +6632,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6964,6 +7005,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -7087,6 +7132,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} @@ -7370,6 +7418,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -7724,6 +7776,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -7940,10 +7996,6 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - obug@2.1.2: - resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} - engines: {node: '>=12.20.0'} - ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -8036,6 +8088,10 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -8044,6 +8100,10 @@ packages: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-queue@9.3.0: resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} engines: {node: '>=20'} @@ -8052,6 +8112,10 @@ packages: resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} engines: {node: '>=20'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -8082,6 +8146,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-expression-matcher@1.5.0: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} @@ -8192,16 +8260,15 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} - engines: {node: '>=18'} - hasBin: true - plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -8449,8 +8516,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.21.6: - resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==} + react-native-keyboard-controller@1.21.7: + resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==} peerDependencies: react: '*' react-native: '*' @@ -8512,6 +8579,12 @@ packages: peerDependencies: react-native: '*' + react-native-webview@13.16.1: + resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets@0.8.3: resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} peerDependencies: @@ -8916,9 +8989,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -9000,9 +9070,6 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -9200,10 +9267,6 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - tldts-core@7.4.2: resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} @@ -9271,6 +9334,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -9569,61 +9636,6 @@ packages: vite: optional: true - vitest-browser-react@2.2.0: - resolution: {integrity: sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==} - peerDependencies: - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - vitest: ^4.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': 24.12.4 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -9767,11 +9779,6 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - widest-line@6.0.0: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} @@ -10864,18 +10871,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10890,9 +10897,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10907,13 +10914,45 @@ snapshots: - react - react-dom - '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': + optional: true + + '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': + optional: true + + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': + optional: true + + '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': + optional: true + + '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': + optionalDependencies: + '@clerk/electron-passkeys-darwin-arm64': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-darwin-x64': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-win32-x64-msvc': 0.0.2-snapshot.v20260622234151 + + '@clerk/electron@0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + electron: 41.5.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 + electron-store: 8.2.0 + react-dom: 19.2.6(react@19.2.6) + + '@clerk/expo@3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + dependencies: + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -10926,21 +10965,21 @@ snapshots: expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -10950,7 +10989,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11525,7 +11564,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server: 56.0.4 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -11623,7 +11662,7 @@ snapshots: '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -11690,7 +11729,7 @@ snapshots: dependencies: '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 @@ -11722,7 +11761,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -11744,7 +11783,7 @@ snapshots: dependencies: '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) pretty-format: 29.7.0 react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -11822,7 +11861,7 @@ snapshots: '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 @@ -11846,7 +11885,7 @@ snapshots: '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 @@ -12116,7 +12155,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12124,7 +12163,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) @@ -13662,11 +13701,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@tanstack/react-query@5.100.14(react@19.2.6)': - dependencies: - '@tanstack/query-core': 5.100.14 - react: 19.2.6 - '@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/history': 1.162.0 @@ -13975,48 +14009,6 @@ snapshots: '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.0.3) babel-plugin-react-compiler: 1.0.0 - '@vitest/expect@4.1.8': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': - dependencies: - '@vitest/spy': 4.1.8 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - - '@vitest/pretty-format@4.1.8': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.8': - dependencies: - '@vitest/utils': 4.1.8 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.8': {} - - '@vitest/utils@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.133.0 @@ -14227,6 +14219,10 @@ snapshots: optionalDependencies: ajv: 8.20.0 + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -14522,6 +14518,8 @@ snapshots: at-least-node@1.0.0: {} + atomically@1.7.0: {} + auto-bind@5.0.1: {} aws-ssl-profiles@1.1.2: {} @@ -14626,7 +14624,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) transitivePeerDependencies: - '@babel/core' @@ -14806,8 +14804,6 @@ snapshots: ccount@2.0.1: {} - chai@6.2.2: {} - chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -14990,6 +14986,19 @@ snapshots: concat-map@0.0.1: {} + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.8.1 + connect@3.7.0: dependencies: debug: 2.6.9 @@ -15092,6 +15101,10 @@ snapshots: culori@4.0.2: {} + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -15221,6 +15234,10 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -15311,6 +15328,11 @@ snapshots: transitivePeerDependencies: - supports-color + electron-store@8.2.0: + dependencies: + conf: 10.2.0 + type-fest: 2.19.0 + electron-to-chromium@1.5.364: {} electron-updater@6.8.3: @@ -15480,10 +15502,6 @@ snapshots: estree-walker@2.0.2: {} - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -15496,16 +15514,14 @@ snapshots: dependencies: eventsource-parser: 3.1.0 - expect-type@1.3.0: {} - expo-application@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -15530,14 +15546,14 @@ snapshots: expo-build-properties@56.0.16(expo@56.0.8): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 semver: 7.8.1 expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -15545,25 +15561,25 @@ snapshots: expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.8) @@ -15575,18 +15591,18 @@ snapshots: expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.8) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu-interface: 56.0.1(expo@56.0.8) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -15594,40 +15610,40 @@ snapshots: expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) fontfaceobserver: 2.3.0 react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-picker@56.0.15(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader: 56.0.3(expo@56.0.8) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): @@ -15642,7 +15658,7 @@ snapshots: expo-manifests@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.14(typescript@6.0.3): @@ -15669,12 +15685,17 @@ snapshots: dependencies: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-network@56.0.5(expo@56.0.8)(react@19.2.3): + dependencies: + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react: 19.2.3 + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-application: 56.0.3(expo@56.0.8) expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 @@ -15685,7 +15706,7 @@ snapshots: expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -15704,7 +15725,7 @@ snapshots: color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -15740,7 +15761,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server@56.0.4: {} @@ -15748,7 +15769,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -15759,7 +15780,7 @@ snapshots: expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -15767,7 +15788,7 @@ snapshots: expo-updates-interface@56.0.2(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: @@ -15777,7 +15798,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.8) expo-structured-headers: 56.0.0 @@ -15796,14 +15817,14 @@ snapshots: expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): dependencies: '@expo/plist': 0.7.0 '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -15814,7 +15835,7 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): + expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): dependencies: '@babel/runtime': 7.29.7 '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) @@ -15844,6 +15865,7 @@ snapshots: '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) + react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -16031,6 +16053,10 @@ snapshots: find-my-way-ts@0.1.6: {} + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + flattie@1.1.1: {} flow-enums-runtime@0.0.6: {} @@ -16092,9 +16118,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -16560,6 +16583,8 @@ snapshots: is-number@7.0.0: {} + is-obj@2.0.0: {} + is-plain-obj@4.1.0: {} is-promise@4.0.0: {} @@ -16660,6 +16685,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@7.0.3: {} + json-schema-typed@8.0.2: {} json-stringify-safe@5.0.1: @@ -16887,6 +16914,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} @@ -17535,6 +17567,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@3.1.0: {} + mimic-function@5.0.1: {} mimic-response@1.0.1: {} @@ -17742,8 +17776,6 @@ snapshots: obug@2.1.1: {} - obug@2.1.2: {} - ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -17877,6 +17909,10 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -17885,6 +17921,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-queue@9.3.0: dependencies: eventemitter3: 5.0.4 @@ -17892,6 +17932,8 @@ snapshots: p-timeout@7.0.1: {} + p-try@2.2.0: {} + package-manager-detector@1.6.0: {} pako@1.0.11: {} @@ -17929,6 +17971,8 @@ snapshots: path-browserify@1.0.1: {} + path-exists@3.0.0: {} + path-expression-matcher@1.5.0: {} path-is-absolute@1.0.1: {} @@ -18021,13 +18065,11 @@ snapshots: pkce-challenge@5.0.1: {} - playwright-core@1.60.0: {} - - playwright@1.60.0: + pkg-up@3.1.0: dependencies: - playwright-core: 1.60.0 - optionalDependencies: - fsevents: 2.3.2 + find-up: 3.0.0 + + playwright-core@1.60.0: {} plist@3.1.0: dependencies: @@ -18269,7 +18311,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -18329,6 +18371,13 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-3 + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 @@ -18966,8 +19015,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -19039,8 +19086,6 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 - stackback@0.0.2: {} - stackframe@1.3.4: {} stacktrace-parser@0.1.11: @@ -19228,8 +19273,6 @@ snapshots: tinypool@2.1.0: {} - tinyrainbow@3.1.0: {} - tldts-core@7.4.2: {} tldts@7.4.2: @@ -19279,6 +19322,8 @@ snapshots: type-fest@0.7.1: {} + type-fest@2.19.0: {} + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -19571,42 +19616,6 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - vitest: 4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - - vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.2 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.12.4 - transitivePeerDependencies: - - msw - vlq@1.0.1: {} volar-service-css@0.0.70(@volar/language-service@2.4.28): @@ -19746,11 +19755,6 @@ snapshots: dependencies: isexe: 4.0.0 - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - widest-line@6.0.0: dependencies: string-width: 8.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 233680b725f..8096d3a0a3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,13 @@ packages: - scripts catalog: + "@clerk/backend": 3.8.3-snapshot.v20260622234151 + "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151 + "@clerk/electron": 0.0.2-snapshot.v20260622234151 + "@clerk/electron-passkeys": 0.0.2-snapshot.v20260622234151 + "@clerk/expo": 3.5.3-snapshot.v20260622234151 + "@clerk/react": 6.11.0-snapshot.v20260622234151 + "@clerk/shared": 4.21.0-snapshot.v20260622234151 "@effect/atom-react": 4.0.0-beta.78 "@effect/openapi-generator": 4.0.0-beta.78 "@effect/platform-bun": 4.0.0-beta.78 @@ -40,6 +47,16 @@ onlyBuiltDependencies: - sharp overrides: + # Keep every Clerk consumer on the same snapshot train. Clerk publishes wallet + # auth integrations as required dependencies, but T3 Code does not support + # wallet auth, so keep that unused dependency tree out of installs. + "@clerk/backend": "catalog:" + "@clerk/clerk-js": "catalog:" + "@clerk/electron": "catalog:" + "@clerk/electron-passkeys": "catalog:" + "@clerk/expo": "catalog:" + "@clerk/react": "catalog:" + "@clerk/shared": "catalog:" "@clerk/clerk-js>@base-org/account": "-" "@clerk/clerk-js>@coinbase/wallet-sdk": "-" "@clerk/clerk-js>@solana/wallet-adapter-base": "-" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8135f7e259d..99aea602e8c 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -4,11 +4,26 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { + BuildCommandFailedError, createStageWorkspaceConfig, createStagePnpmConfig, + createBuildConfig, DESKTOP_ASAR_UNPACK, + InvalidMacPasskeyRpDomainError, + InvalidMacPasskeyPublishableKeyError, + InvalidMockUpdateServerPortError, + isMacPasskeySigningConfigurationError, + LinuxIconResizeError, + MacPasskeySigningConfigurationResolutionError, + MissingMacPasskeyProvisioningProfileError, + renderMacPasskeyEntitlements, + resolveClerkPasskeyNativeArtifacts, + resolveMacPasskeySigningConfiguration, resolveDesktopRuntimeDependencies, resolveFffNativeDependencies, resolveBuildOptions, @@ -18,11 +33,49 @@ import { resolveGitHubPublishConfig, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, + stageLinuxIconSize, STAGE_INSTALL_ARGS, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +function mockProcess(exitCode: number) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function iconResizeSpawnerLayer( + commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>, + exitCodes: ReadonlyArray, +) { + let commandIndex = 0; + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push({ + command: childProcess.command, + args: childProcess.args, + }); + return Effect.succeed(mockProcess(exitCodes[commandIndex++] ?? 0)); + }), + ); +} + it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("resolves the dedicated nightly updater channel from nightly versions", () => { assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); @@ -175,6 +228,178 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); }); + it.effect("preserves both Linux icon resize failures with structural context", () => { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + return Effect.gen(function* () { + const error = yield* stageLinuxIconSize("source.png", "target.png", 512, false).pipe( + Effect.provide(iconResizeSpawnerLayer(commands, [1, 2])), + Effect.flip, + ); + + assert.instanceOf(error, LinuxIconResizeError); + assert.equal(error.operation, "resize"); + assert.equal(error.iconSize, 512); + assert.equal(error.primaryTool, "magick"); + assert.equal(error.fallbackTool, "convert"); + assert.include(error.message, "512x512"); + assert.include(error.message, "`magick`"); + assert.include(error.message, "`convert`"); + assert.notInclude(error.message, "non-zero exit code"); + + assert.instanceOf(error.cause, AggregateError); + const aggregateCause = error.cause as AggregateError; + assert.lengthOf(aggregateCause.errors, 2); + assert.strictEqual(aggregateCause.cause, aggregateCause.errors[0]); + assert.instanceOf(aggregateCause.errors[0], BuildCommandFailedError); + assert.instanceOf(aggregateCause.errors[1], BuildCommandFailedError); + const primaryError = aggregateCause.errors[0] as BuildCommandFailedError; + const fallbackError = aggregateCause.errors[1] as BuildCommandFailedError; + assert.equal(primaryError.command, "magick linux icon 512x512"); + assert.equal(primaryError.exitCode, 1); + assert.include(primaryError.message, "magick linux icon"); + assert.equal(fallbackError.command, "convert linux icon 512x512"); + assert.equal(fallbackError.exitCode, 2); + assert.include(fallbackError.message, "convert linux icon"); + assert.deepStrictEqual( + commands.map(({ command }) => command), + ["magick", "convert"], + ); + }); + }); + + it("derives macOS passkey signing configuration from the Clerk publishable key", () => { + const configuration = resolveMacPasskeySigningConfiguration({ + T3CODE_APPLE_TEAM_ID: "abc1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PUBLISHABLE_KEY: `pk_test_${btoa("example.clerk.accounts.dev$")}`, + }); + + assert.deepStrictEqual(configuration, { + appId: "com.t3tools.t3code", + teamId: "ABC1234567", + rpDomains: ["example.clerk.accounts.dev"], + provisioningProfilePath: "/tmp/t3code.provisionprofile", + }); + }); + + it("normalizes explicit macOS passkey RP domains and renders required entitlements", () => { + const configuration = resolveMacPasskeySigningConfiguration({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: + " Clerk.Example.com,example.clerk.accounts.dev,clerk.example.com ", + }); + const entitlements = renderMacPasskeyEntitlements(configuration); + + assert.deepStrictEqual(configuration.rpDomains, [ + "clerk.example.com", + "example.clerk.accounts.dev", + ]); + assert.include(entitlements, "ABC1234567.com.t3tools.t3code"); + assert.include(entitlements, "webcredentials:clerk.example.com"); + assert.include(entitlements, "webcredentials:example.clerk.accounts.dev"); + assert.include(entitlements, "com.apple.security.cs.allow-jit"); + }); + + it("rejects incomplete macOS passkey signing configuration", () => { + const captureError = (env: Readonly>) => { + try { + resolveMacPasskeySigningConfiguration(env); + } catch (error) { + return error; + } + return assert.fail("Expected passkey signing configuration to fail."); + }; + + const missingProfileError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev", + }); + assert.instanceOf(missingProfileError, MissingMacPasskeyProvisioningProfileError); + assert.equal( + missingProfileError.message, + "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.", + ); + + const unsafeDomain = + "https://domain-user:domain-secret@example.clerk.accounts.dev/path?token=query-secret"; + const invalidDomainError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: unsafeDomain, + }); + assert.instanceOf(invalidDomainError, InvalidMacPasskeyRpDomainError); + assert.equal(invalidDomainError.reason, "scheme-not-allowed"); + assert.equal(invalidDomainError.inputLength, unsafeDomain.length); + assert.equal(invalidDomainError.message, "Invalid passkey RP domain (scheme-not-allowed)."); + assert.notProperty(invalidDomainError, "domain"); + assert.notProperty(invalidDomainError, "cause"); + const serializedInvalidDomainError = JSON.stringify(invalidDomainError); + assert.notInclude(serializedInvalidDomainError, unsafeDomain); + assert.notInclude(serializedInvalidDomainError, "domain-user"); + assert.notInclude(serializedInvalidDomainError, "domain-secret"); + assert.notInclude(serializedInvalidDomainError, "query-secret"); + assert.throws( + () => + resolveMacPasskeySigningConfiguration({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev:8443", + }), + /Invalid passkey RP domain/u, + ); + const invalidPublishableKeyError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_%", + }); + assert.instanceOf(invalidPublishableKeyError, InvalidMacPasskeyPublishableKeyError); + assert.ok(invalidPublishableKeyError.cause); + assert.equal(invalidPublishableKeyError.message, "T3CODE_CLERK_PUBLISHABLE_KEY is invalid."); + assert.notProperty(invalidPublishableKeyError, "publishableKey"); + assert.notInclude(invalidPublishableKeyError.message, "pk_test_%"); + }); + + it("preserves known passkey signing configuration errors at the build boundary", () => { + const decodingCause = new Error("publishable-key-decode-failed"); + const knownError = new InvalidMacPasskeyPublishableKeyError({ cause: decodingCause }); + const error = MacPasskeySigningConfigurationResolutionError.fromCause(knownError); + + assert.strictEqual(error, knownError); + assert.instanceOf(error, InvalidMacPasskeyPublishableKeyError); + assert.strictEqual(error.cause, decodingCause); + assert.isTrue(isMacPasskeySigningConfigurationError(error)); + }); + + it("wraps unknown passkey signing configuration defects without copying cause text", () => { + const secret = "pk_test_do-not-retain"; + const cause = new Error(secret); + const error = MacPasskeySigningConfigurationResolutionError.fromCause(cause); + + assert.instanceOf(error, MacPasskeySigningConfigurationResolutionError); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to resolve macOS passkey signing configuration."); + assert.notInclude(error.message, secret); + }); + + it.effect("adds passkey entitlements and both renderer protocols to signed macOS builds", () => + Effect.gen(function* () { + const config = yield* createBuildConfig("mac", "dmg", "1.2.3", true, false, undefined, { + entitlementsPath: "/tmp/entitlements.mac.plist", + provisioningProfilePath: "/tmp/t3code.provisionprofile", + }); + + const mac = config.mac as Record; + assert.equal(config.appId, "com.t3tools.t3code"); + assert.equal(mac.entitlements, "/tmp/entitlements.mac.plist"); + assert.equal(mac.provisioningProfile, "/tmp/t3code.provisionprofile"); + assert.deepStrictEqual(mac.protocols, [ + { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + ]); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); + it("promotes target fff binaries to direct staged dependencies", () => { assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", @@ -192,6 +417,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); }); + it("resolves target Clerk passkey native artifacts", () => { + assert.deepStrictEqual(resolveClerkPasskeyNativeArtifacts("mac", "universal"), [ + { + packageName: "@clerk/electron-passkeys-darwin-arm64", + binaryFileName: "electron-passkeys.darwin-arm64.node", + }, + { + packageName: "@clerk/electron-passkeys-darwin-x64", + binaryFileName: "electron-passkeys.darwin-x64.node", + }, + ]); + assert.deepStrictEqual(resolveClerkPasskeyNativeArtifacts("win", "x64"), [ + { + packageName: "@clerk/electron-passkeys-win32-x64-msvc", + binaryFileName: "electron-passkeys.win32-x64-msvc.node", + }, + ]); + assert.deepStrictEqual(resolveClerkPasskeyNativeArtifacts("linux", "x64"), []); + }); + it("falls back to the default mock update port when the configured port is blank", () => { assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000"); assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); @@ -216,6 +461,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it("classifies invalid configured ports with the decoder's number grammar", () => { + const cause = new Error("invalid configured port"); + + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("0x10", cause).reason, + "not-numeric", + ); + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("12.5", cause).reason, + "not-integer", + ); + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("65536", cause).reason, + "out-of-range", + ); + assert.strictEqual( + InvalidMockUpdateServerPortError.fromConfigValue("0x10", cause).cause, + cause, + ); + }); + it.effect("resolves default platform and architecture from host references", () => Effect.gen(function* () { const resolved = yield* resolveBuildOptions({ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index b9788ddfa7c..1bc73272159 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,7 +1,10 @@ #!/usr/bin/env node +import * as NodeModule from "node:module"; + import { fromYaml } from "@t3tools/shared/schemaYaml"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; @@ -9,12 +12,12 @@ import serverPackageJson from "../apps/server/package.json" with { type: "json" import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; +import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -27,6 +30,8 @@ import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; +const DESKTOP_APP_ID = "com.t3tools.t3code"; +const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); @@ -120,10 +125,255 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B return yield* getDefaultBuildArch(platform, config); }); -class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class MacPasskeySigningConfigurationResolutionError extends Schema.TaggedErrorClass()( + "MacPasskeySigningConfigurationResolutionError", + { + cause: Schema.Defect(), + }, +) { + static fromCause( + cause: unknown, + ): MacPasskeySigningConfigurationError | MacPasskeySigningConfigurationResolutionError { + return isMacPasskeySigningConfigurationError(cause) + ? cause + : new MacPasskeySigningConfigurationResolutionError({ cause }); + } + + override get message(): string { + return "Failed to resolve macOS passkey signing configuration."; + } +} + +export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedErrorClass()( + "ClerkPasskeyNativePackageMissingError", + { + packageName: Schema.String, + binaryFileName: Schema.String, + packageEntryPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Clerk passkey native package is missing: ${this.packageName}`; + } +} + +export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass()( + "UnsupportedHostBuildPlatformError", + { + hostPlatform: Schema.String, + }, +) { + override get message(): string { + return `Unsupported host platform '${this.hostPlatform}'.`; + } +} + +const InvalidMockUpdateServerPortReason = Schema.Literals([ + "not-numeric", + "not-integer", + "out-of-range", +]); + +export class InvalidMockUpdateServerPortError extends Schema.TaggedErrorClass()( + "InvalidMockUpdateServerPortError", + { + reason: InvalidMockUpdateServerPortReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid mock update server port."; + } + + static fromConfigValue(configuredPort: string, cause: unknown) { + return new InvalidMockUpdateServerPortError({ + reason: invalidMockUpdateServerPortReason(configuredPort), + inputLength: configuredPort.length, + cause, + }); + } +} + +export class BuildCommandFailedError extends Schema.TaggedErrorClass()( + "BuildCommandFailedError", + { + command: Schema.String, + exitCode: Schema.Int, + stdoutTail: Schema.optionalKey(Schema.String), + stderrTail: Schema.optionalKey(Schema.String), + }, +) { + override get message(): string { + const outputSections = [ + `Command: ${this.command}`, + formatOutputSection("stdout", this.stdoutTail ?? ""), + formatOutputSection("stderr", this.stderrTail ?? ""), + ].filter((section): section is string => section !== undefined); + const outputSuffix = outputSections.length > 0 ? `\n\n${outputSections.join("\n\n")}` : ""; + return `Command exited with non-zero exit code (${this.exitCode})${outputSuffix}`; + } +} + +const desktopIconPlatformNames = { + mac: "macOS", + linux: "Linux", + win: "Windows", +} satisfies Record; + +export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass()( + "DesktopIconSourceMissingError", + { + platform: BuildPlatform, + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Desktop ${desktopIconPlatformNames[this.platform]} icon source is missing at ${this.sourcePath}`; + } +} + +export class BundledClientAssetsMissingError extends Schema.TaggedErrorClass()( + "BundledClientAssetsMissingError", + { + indexPath: Schema.String, + missingFiles: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const preview = this.missingFiles.slice(0, 6).join(", "); + const suffix = this.missingFiles.length > 6 ? ` (+${this.missingFiles.length - 6} more)` : ""; + return `Bundled client references missing files in ${this.indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`; + } +} + +export class UnsupportedDesktopBuildPlatformError extends Schema.TaggedErrorClass()( + "UnsupportedDesktopBuildPlatformError", + { + platform: Schema.String, + }, +) { + override get message(): string { + return `Unsupported platform '${this.platform}'.`; + } +} + +const dependencyResolutionDescriptions = { + "server-production": "production dependencies", + "workspace-overrides": "overrides", + "desktop-runtime": "desktop runtime dependencies", +} as const; +const DependencyResolutionKind = Schema.Literals([ + "server-production", + "workspace-overrides", + "desktop-runtime", +]); + +export class DesktopBuildDependencyResolutionError extends Schema.TaggedErrorClass()( + "DesktopBuildDependencyResolutionError", + { + kind: DependencyResolutionKind, + manifestPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not resolve ${dependencyResolutionDescriptions[this.kind]} from ${this.manifestPath}.`; + } +} + +export class MissingServerProductionDependenciesError extends Schema.TaggedErrorClass()( + "MissingServerProductionDependenciesError", + { + manifestPath: Schema.String, + }, +) { + override get message(): string { + return `Could not resolve production dependencies from ${this.manifestPath}.`; + } +} + +const DesktopBuildInputArtifact = Schema.Literals([ + "desktop-dist", + "desktop-resources", + "server-dist", + "bundled-server-client", +]); +type DesktopBuildInputArtifact = typeof DesktopBuildInputArtifact.Type; +const desktopBuildInputArtifactNames = { + "desktop-dist": "desktopDist", + "desktop-resources": "desktopResources", + "server-dist": "serverDist", + "bundled-server-client": "bundled server client", +} satisfies Record; + +export class MissingDesktopBuildInputError extends Schema.TaggedErrorClass()( + "MissingDesktopBuildInputError", + { + artifact: DesktopBuildInputArtifact, + artifactPath: Schema.String, + buildCommand: Schema.Literal("vp run build:desktop"), + }, +) { + override get message(): string { + return `Missing ${desktopBuildInputArtifactNames[this.artifact]} at ${this.artifactPath}. Run '${this.buildCommand}' first.`; + } +} + +export class MacProvisioningProfileNotFoundError extends Schema.TaggedErrorClass()( + "MacProvisioningProfileNotFoundError", + { + provisioningProfilePath: Schema.String, + }, +) { + override get message(): string { + return `macOS provisioning profile not found: ${this.provisioningProfilePath}`; + } +} + +export class DesktopBuildDistDirectoryMissingError extends Schema.TaggedErrorClass()( + "DesktopBuildDistDirectoryMissingError", + { + distPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + }, +) { + override get message(): string { + return `Build completed but dist directory was not found at ${this.distPath}`; + } +} + +export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedErrorClass()( + "DesktopBuildNoArtifactsProducedError", + { + distPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + }, +) { + override get message(): string { + return `Build completed but no files were produced in ${this.distPath}`; + } +} + +export class LinuxIconResizeError extends Schema.TaggedErrorClass()( + "LinuxIconResizeError", + { + operation: Schema.Literal("resize"), + iconSize: Schema.Int, + primaryTool: Schema.Literal("magick"), + fallbackTool: Schema.Literal("convert"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} the Linux desktop icon to ${this.iconSize}x${this.iconSize} with \`${this.primaryTool}\` or \`${this.fallbackTool}\`. Install ImageMagick so either tool is available.`; + } +} const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( @@ -293,6 +543,223 @@ interface StagePackageJson { export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; +export interface MacPasskeySigningConfiguration { + readonly appId: string; + readonly teamId: string; + readonly rpDomains: readonly string[]; + readonly provisioningProfilePath: string; +} + +export const InvalidMacPasskeyRpDomainReason = Schema.Literals([ + "empty", + "scheme-not-allowed", + "parse-failed", + "credentials-not-allowed", + "port-not-allowed", + "path-not-allowed", + "query-not-allowed", + "fragment-not-allowed", + "hostname-mismatch", +]); +export type InvalidMacPasskeyRpDomainReason = typeof InvalidMacPasskeyRpDomainReason.Type; + +export class InvalidMacPasskeyRpDomainError extends Schema.TaggedErrorClass()( + "InvalidMacPasskeyRpDomainError", + { + reason: InvalidMacPasskeyRpDomainReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid passkey RP domain (${this.reason}).`; + } +} + +export class InvalidAppleTeamIdError extends Schema.TaggedErrorClass()( + "InvalidAppleTeamIdError", + { + teamId: Schema.String, + }, +) { + override get message(): string { + return `T3CODE_APPLE_TEAM_ID '${this.teamId}' must be a 10-character Apple Developer Team ID.`; + } +} + +export class MissingMacPasskeyProvisioningProfileError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyProvisioningProfileError", + {}, +) { + override get message(): string { + return "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile."; + } +} + +export class MissingMacPasskeyDomainConfigurationError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyDomainConfigurationError", + {}, +) { + override get message(): string { + return "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds."; + } +} + +export class InvalidMacPasskeyPublishableKeyError extends Schema.TaggedErrorClass()( + "InvalidMacPasskeyPublishableKeyError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "T3CODE_CLERK_PUBLISHABLE_KEY is invalid."; + } +} + +export class MissingMacPasskeyRpDomainError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyRpDomainError", + {}, +) { + override get message(): string { + return "At least one Clerk passkey RP domain is required."; + } +} + +export const MacPasskeySigningConfigurationError = Schema.Union([ + InvalidMacPasskeyRpDomainError, + InvalidAppleTeamIdError, + MissingMacPasskeyProvisioningProfileError, + MissingMacPasskeyDomainConfigurationError, + InvalidMacPasskeyPublishableKeyError, + MissingMacPasskeyRpDomainError, +]); +export type MacPasskeySigningConfigurationError = typeof MacPasskeySigningConfigurationError.Type; +export const isMacPasskeySigningConfigurationError = Schema.is(MacPasskeySigningConfigurationError); + +function normalizePasskeyRpDomain(value: string): string { + const normalized = value.trim().toLowerCase(); + const inputLength = value.length; + if (normalized.length === 0) { + throw new InvalidMacPasskeyRpDomainError({ reason: "empty", inputLength }); + } + if (/^[a-z][a-z\d+.-]*:\/\//u.test(normalized)) { + throw new InvalidMacPasskeyRpDomainError({ + reason: "scheme-not-allowed", + inputLength, + }); + } + + let parsed: URL; + try { + parsed = new URL(`https://${normalized}`); + } catch (cause) { + throw new InvalidMacPasskeyRpDomainError({ reason: "parse-failed", inputLength, cause }); + } + + let reason: InvalidMacPasskeyRpDomainReason | undefined; + if (parsed.username.length > 0 || parsed.password.length > 0) { + reason = "credentials-not-allowed"; + } else if (parsed.port.length > 0) { + reason = "port-not-allowed"; + } else if (parsed.pathname !== "/") { + reason = "path-not-allowed"; + } else if (parsed.search.length > 0) { + reason = "query-not-allowed"; + } else if (parsed.hash.length > 0) { + reason = "fragment-not-allowed"; + } else if (parsed.host !== normalized) { + reason = "hostname-mismatch"; + } + if (reason) { + throw new InvalidMacPasskeyRpDomainError({ reason, inputLength }); + } + + return parsed.hostname; +} + +export function resolveMacPasskeySigningConfiguration( + env: Readonly>, +): MacPasskeySigningConfiguration { + const teamId = env.T3CODE_APPLE_TEAM_ID?.trim().toUpperCase() ?? ""; + if (!APPLE_TEAM_ID_PATTERN.test(teamId)) { + throw new InvalidAppleTeamIdError({ teamId }); + } + + const provisioningProfilePath = env.T3CODE_MACOS_PROVISIONING_PROFILE?.trim() ?? ""; + if (provisioningProfilePath.length === 0) { + throw new MissingMacPasskeyProvisioningProfileError(); + } + + const configuredRpDomains = env.T3CODE_CLERK_PASSKEY_RP_DOMAINS?.trim(); + let rpDomains: readonly string[]; + if (configuredRpDomains) { + rpDomains = configuredRpDomains.split(",").map(normalizePasskeyRpDomain); + } else { + const publishableKey = env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim(); + if (!publishableKey) { + throw new MissingMacPasskeyDomainConfigurationError(); + } + let hostname: string; + try { + hostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); + } catch (cause) { + throw new InvalidMacPasskeyPublishableKeyError({ cause }); + } + rpDomains = [normalizePasskeyRpDomain(hostname)]; + } + + const uniqueRpDomains = [...new Set(rpDomains)]; + if (uniqueRpDomains.length === 0) { + throw new MissingMacPasskeyRpDomainError(); + } + + return { + appId: DESKTOP_APP_ID, + teamId, + rpDomains: uniqueRpDomains, + provisioningProfilePath, + }; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderMacPasskeyEntitlements( + configuration: MacPasskeySigningConfiguration, +): string { + const associatedDomains = configuration.rpDomains + .map((domain) => ` webcredentials:${escapeXml(domain)}`) + .join("\n"); + + return ` + + + + com.apple.application-identifier + ${escapeXml(`${configuration.teamId}.${configuration.appId}`)} + com.apple.developer.team-identifier + ${escapeXml(configuration.teamId)} + com.apple.developer.associated-domains + +${associatedDomains} + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + +`; +} + export function resolveFffNativeDependencies( platform: typeof BuildPlatform.Type, arch: typeof BuildArch.Type, @@ -319,6 +786,67 @@ export function resolveFffNativeDependencies( ); } +export interface ClerkPasskeyNativeArtifact { + readonly packageName: string; + readonly binaryFileName: string; +} + +export function resolveClerkPasskeyNativeArtifacts( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +): readonly ClerkPasskeyNativeArtifact[] { + const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch]; + + if (platform === "mac") { + return architectures.map((architecture) => ({ + packageName: `@clerk/electron-passkeys-darwin-${architecture}`, + binaryFileName: `electron-passkeys.darwin-${architecture}.node`, + })); + } + + if (platform === "win") { + return architectures.map((architecture) => ({ + packageName: `@clerk/electron-passkeys-win32-${architecture}-msvc`, + binaryFileName: `electron-passkeys.win32-${architecture}-msvc.node`, + })); + } + + return []; +} + +// pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only +// retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage +// the binary there and let electron-builder's native-addon handling unpack it from the ASAR. +const stageClerkPasskeyNativeBinaries = Effect.fn("stageClerkPasskeyNativeBinaries")(function* ( + stageAppDir: string, + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const packageEntryPath = yield* fs.realPath( + path.join(stageAppDir, "node_modules", "@clerk", "electron-passkeys", "index.js"), + ); + const packageDir = path.dirname(packageEntryPath); + const packageRequire = NodeModule.createRequire(packageEntryPath); + + for (const artifact of resolveClerkPasskeyNativeArtifacts(platform, arch)) { + const sourcePath = yield* Effect.try({ + try: () => packageRequire.resolve(artifact.packageName), + catch: (cause) => + new ClerkPasskeyNativePackageMissingError({ + packageName: artifact.packageName, + binaryFileName: artifact.binaryFileName, + packageEntryPath, + platform, + arch, + cause, + }), + }); + yield* fs.copyFile(sourcePath, path.join(packageDir, artifact.binaryFileName)); + } +}); + export function createStageWorkspaceConfig( platform: typeof BuildPlatform.Type, arch: typeof BuildArch.Type, @@ -385,6 +913,18 @@ const MockUpdateServerPortSchema = Schema.NumberFromString.check( ); const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema); +function invalidMockUpdateServerPortReason( + configuredPort: string, +): typeof InvalidMockUpdateServerPortReason.Type { + const parsed = Number(configuredPort); + if (!Number.isFinite(parsed)) return "not-numeric"; + if (!Number.isInteger(parsed)) return "not-integer"; + if (parsed < 1 || parsed > 65535) return "out-of-range"; + // This mapper is only called after schema decoding failed. An otherwise + // valid integer therefore used a representation the decoder did not accept. + return "not-numeric"; +} + const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(flag, () => envValue); const mergeOptions = (a: Option.Option, b: Option.Option, defaultValue: A) => @@ -416,9 +956,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( ); if (!platform) { - return yield* new BuildScriptError({ - message: `Unsupported host platform '${hostPlatform}'.`, - }); + return yield* new UnsupportedHostBuildPlatformError({ hostPlatform }); } const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); @@ -439,17 +977,16 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const verbose = resolveBooleanFlag(input.verbose, env.verbose); const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates); + const configuredMockUpdateServerPort = Option.getOrUndefined(env.mockUpdateServerPort); const mockUpdateServerPort = Option.getOrUndefined(input.mockUpdateServerPort) ?? - (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe( - Effect.mapError( - (cause) => - new BuildScriptError({ - message: "Invalid mock update server port.", - cause, - }), - ), - )); + (configuredMockUpdateServerPort === undefined + ? undefined + : yield* resolveMockUpdateServerPort(configuredMockUpdateServerPort).pipe( + Effect.mapError((cause) => + InvalidMockUpdateServerPortError.fromConfigValue(configuredMockUpdateServerPort, cause), + ), + )); return { platform, @@ -469,7 +1006,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const runCommand = Effect.fn("runCommand")(function* ( command: ChildProcess.Command, options: { - readonly label?: string; + readonly label: string; readonly verbose: boolean; }, ) { @@ -485,14 +1022,11 @@ const runCommand = Effect.fn("runCommand")(function* ( ); if (exitCode !== 0) { - const outputSections = [ - options.label ? `Command: ${options.label}` : undefined, - formatOutputSection("stdout", stdout), - formatOutputSection("stderr", stderr), - ].filter((section): section is string => section !== undefined); - const outputSuffix = outputSections.length > 0 ? `\n\n${outputSections.join("\n\n")}` : ""; - return yield* new BuildScriptError({ - message: `Command exited with non-zero exit code (${exitCode})${outputSuffix}`, + return yield* new BuildCommandFailedError({ + command: options.label, + exitCode, + ...(stdout.trim() ? { stdoutTail: stdout } : {}), + ...(stderr.trim() ? { stderrTail: stderr } : {}), }); } }); @@ -539,8 +1073,9 @@ function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: bo const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourcePng))) { - return yield* new BuildScriptError({ - message: `Desktop macOS icon source is missing at ${sourcePng}`, + return yield* new DesktopIconSourceMissingError({ + platform: "mac", + sourcePath: sourcePng, }); } @@ -565,8 +1100,9 @@ function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourcePng))) { - return yield* new BuildScriptError({ - message: `Desktop Linux icon source is missing at ${sourcePng}`, + return yield* new DesktopIconSourceMissingError({ + platform: "linux", + sourcePath: sourcePng, }); } @@ -586,7 +1122,7 @@ function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: }); } -function stageLinuxIconSize( +export function stageLinuxIconSize( sourcePng: string, targetPng: string, iconSize: number, @@ -599,13 +1135,20 @@ function stageLinuxIconSize( ); return resize("magick").pipe( - Effect.catch(() => + Effect.catch((primaryCause) => resize("convert").pipe( Effect.mapError( - () => - new BuildScriptError({ - message: - "ImageMagick is required to generate Linux desktop icon sizes. Install ImageMagick so either `magick` or `convert` is available.", + (fallbackCause) => + new LinuxIconResizeError({ + operation: "resize", + iconSize, + primaryTool: "magick", + fallbackTool: "convert", + cause: new AggregateError( + [primaryCause, fallbackCause], + "Both Linux icon resize tool attempts failed.", + { cause: primaryCause }, + ), }), ), ), @@ -618,8 +1161,9 @@ function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourceIco))) { - return yield* new BuildScriptError({ - message: `Desktop Windows icon source is missing at ${sourceIco}`, + return yield* new DesktopIconSourceMissingError({ + platform: "win", + sourcePath: sourceIco, }); } @@ -656,10 +1200,9 @@ function validateBundledClientAssets(clientDir: string) { } if (missing.length > 0) { - const preview = missing.slice(0, 6).join(", "); - const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; - return yield* new BuildScriptError({ - message: `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, + return yield* new BundledClientAssetsMissingError({ + indexPath, + missingFiles: missing, }); } }); @@ -739,16 +1282,22 @@ export function resolveDesktopProductName(version: string): string { : (desktopPackageJson.productName ?? "T3 Code"); } -const createBuildConfig = Effect.fn("createBuildConfig")(function* ( +export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( platform: typeof BuildPlatform.Type, target: string, version: string, signed: boolean, mockUpdates: boolean, mockUpdateServerPort: number | undefined, + macPasskeySigning: + | { + readonly entitlementsPath: string; + readonly provisioningProfilePath: string; + } + | undefined, ) { const buildConfig: Record = { - appId: "com.t3tools.t3code", + appId: DESKTOP_APP_ID, productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", asarUnpack: [...DESKTOP_ASAR_UNPACK], @@ -777,9 +1326,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( protocols: [ { name: "T3 Code", - schemes: ["t3code"], + schemes: ["t3code", "t3code-dev"], }, ], + ...(macPasskeySigning + ? { + entitlements: macPasskeySigning.entitlementsPath, + provisioningProfile: macPasskeySigning.provisioningProfilePath, + } + : {}), }; } @@ -849,8 +1404,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const platformConfig = PLATFORM_CONFIG[options.platform]; if (!platformConfig) { - return yield* new BuildScriptError({ - message: `Unsupported platform '${options.platform}'.`, + return yield* new UnsupportedDesktopBuildPlatformError({ + platform: options.platform, }); } @@ -858,16 +1413,17 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const serverDependencies = serverPackageJson.dependencies; if (!serverDependencies || Object.keys(serverDependencies).length === 0) { - return yield* new BuildScriptError({ - message: "Could not resolve production dependencies from apps/server/package.json.", + return yield* new MissingServerProductionDependenciesError({ + manifestPath: "apps/server/package.json", }); } const resolvedOverrides = yield* Effect.try({ try: () => resolveCatalogDependencies(workspaceOverrides, workspaceCatalog, "apps/desktop"), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve overrides from pnpm-workspace.yaml.", + new DesktopBuildDependencyResolutionError({ + kind: "workspace-overrides", + manifestPath: "pnpm-workspace.yaml", cause, }), }); @@ -875,16 +1431,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const resolvedServerDependencies = yield* Effect.try({ try: () => resolveCatalogDependencies(serverDependencies, workspaceCatalog, "apps/server"), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve production dependencies from apps/server/package.json.", + new DesktopBuildDependencyResolutionError({ + kind: "server-production", + manifestPath: "apps/server/package.json", cause, }), }); const resolvedDesktopRuntimeDependencies = yield* Effect.try({ try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve desktop runtime dependencies from apps/desktop/package.json.", + new DesktopBuildDependencyResolutionError({ + kind: "desktop-runtime", + manifestPath: "apps/desktop/package.json", cause, }), }); @@ -918,17 +1476,25 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); } - for (const [label, dir] of Object.entries(distDirs)) { - if (!(yield* fs.exists(dir))) { - return yield* new BuildScriptError({ - message: `Missing ${label} at ${dir}. Run 'vp run build:desktop' first.`, + const requiredBuildInputs = [ + { artifact: "desktop-dist", artifactPath: distDirs.desktopDist }, + { artifact: "desktop-resources", artifactPath: distDirs.desktopResources }, + { artifact: "server-dist", artifactPath: distDirs.serverDist }, + ] as const; + for (const input of requiredBuildInputs) { + if (!(yield* fs.exists(input.artifactPath))) { + return yield* new MissingDesktopBuildInputError({ + ...input, + buildCommand: "vp run build:desktop", }); } } if (!(yield* fs.exists(bundledClientEntry))) { - return yield* new BuildScriptError({ - message: `Missing bundled server client at ${bundledClientEntry}. Run 'vp run build:desktop' first.`, + return yield* new MissingDesktopBuildInputError({ + artifact: "bundled-server-client", + artifactPath: bundledClientEntry, + buildCommand: "vp run build:desktop", }); } @@ -956,6 +1522,34 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // electron-builder is filtering out stageResourcesDir directory in the AppImage for production yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const configuredMacPasskeySigning = + options.platform === "mac" && options.signed + ? yield* Effect.try({ + try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })), + catch: MacPasskeySigningConfigurationResolutionError.fromCause, + }) + : undefined; + const macPasskeySigning = configuredMacPasskeySigning + ? { + ...configuredMacPasskeySigning, + provisioningProfilePath: path.resolve( + repoRoot, + configuredMacPasskeySigning.provisioningProfilePath, + ), + } + : undefined; + const macEntitlementsPath = macPasskeySigning + ? path.join(stageAppDir, "entitlements.mac.plist") + : undefined; + if (macPasskeySigning && macEntitlementsPath) { + if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) { + return yield* new MacProvisioningProfileNotFoundError({ + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, + }); + } + yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); + } + const stageDependencies = { ...resolvedServerDependencies, ...resolvedDesktopRuntimeDependencies, @@ -983,6 +1577,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.signed, options.mockUpdates, options.mockUpdateServerPort, + macPasskeySigning && macEntitlementsPath + ? { + entitlementsPath: macEntitlementsPath, + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, + } + : undefined, ), dependencies: stageDependencies, devDependencies: { @@ -1014,6 +1614,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }), { label: "vp install --prod", verbose: options.verbose }, ); + yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") // as enabled, so copy the host env and scrub empty values instead of relying @@ -1082,8 +1683,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stageDistDir = path.join(stageAppDir, "dist"); if (!(yield* fs.exists(stageDistDir))) { - return yield* new BuildScriptError({ - message: `Build completed but dist directory was not found at ${stageDistDir}`, + return yield* new DesktopBuildDistDirectoryMissingError({ + distPath: stageDistDir, + platform: options.platform, + arch: options.arch, }); } @@ -1102,8 +1705,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } if (copiedArtifacts.length === 0) { - return yield* new BuildScriptError({ - message: `Build completed but no files were produced in ${stageDistDir}`, + return yield* new DesktopBuildNoArtifactsProducedError({ + distPath: stageDistDir, + platform: options.platform, + arch: options.arch, }); } diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index f6df387ee22..85d57c4181f 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,8 +1,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { checkPortAvailabilityOnHosts, @@ -11,8 +19,49 @@ import { getDevRunnerModeArgs, resolveModePortOffsets, resolveOffset, + runDevRunnerWithInput, } from "./dev-runner.ts"; +const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); +const netServiceLayer = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(49_152), + findAvailablePort: (port) => Effect.succeed(port), +}); + +function mockProcess(exit: number | PlatformError.PlatformError) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: + typeof exit === "number" + ? Effect.succeed(ChildProcessSpawner.ExitCode(exit)) + : Effect.fail(exit), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +const devServerInput = { + mode: "dev:server", + t3Home: "/tmp/t3code-dev-runner", + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: 13_773, + devUrl: undefined, + dryRun: false, + runArgs: ["--inspect", "secret-token-value"], +} as const; + it.layer(NodeServices.layer)("dev-runner", (it) => { describe("getDevRunnerModeArgs", () => { it.effect("lets Vite+ honor the desktop dev task graph", () => @@ -42,8 +91,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("resolveOffset", () => { it.effect("uses explicit T3CODE_PORT_OFFSET when provided", () => - Effect.sync(() => { - const result = resolveOffset({ portOffset: 12, devInstance: undefined }); + Effect.gen(function* () { + const result = yield* resolveOffset({ portOffset: 12, devInstance: undefined }); assert.deepStrictEqual(result, { offset: 12, source: "T3CODE_PORT_OFFSET=12", @@ -52,23 +101,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); it.effect("hashes non-numeric instance values", () => - Effect.sync(() => { - const result = resolveOffset({ portOffset: undefined, devInstance: "feature-branch" }); + Effect.gen(function* () { + const result = yield* resolveOffset({ + portOffset: undefined, + devInstance: "feature-branch", + }); assert.ok(result.offset >= 1); assert.ok(result.offset <= 3000); }), ); - it.effect("throws for negative port offset", () => + it.effect("returns structured context for a negative port offset", () => Effect.gen(function* () { - const error = yield* Effect.flip( - Effect.try({ - try: () => resolveOffset({ portOffset: -1, devInstance: undefined }), - catch: (cause) => String(cause), - }), + const error = yield* resolveOffset({ portOffset: -1, devInstance: undefined }).pipe( + Effect.flip, ); - assert.ok(error.includes("Invalid T3CODE_PORT_OFFSET")); + assert.equal(error._tag, "DevRunnerInvalidPortOffsetError"); + assert.equal(error.configKey, "T3CODE_PORT_OFFSET"); + assert.equal(error.portOffset, -1); + assert.equal(error.minimum, 0); + assert.ok(!("cause" in error)); }), ); }); @@ -289,6 +342,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(offset, 59_802); }), ); + + it.effect("reports the exhausted range and required port set", () => + Effect.gen(function* () { + const error = yield* findFirstAvailableOffset({ + startOffset: 51_763, + requireServerPort: true, + requireWebPort: false, + checkPortAvailability: () => Effect.succeed(true), + }).pipe(Effect.flip); + + if (error._tag !== "DevRunnerPortExhaustedError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.startOffset, 51_763); + assert.equal(error.requireServerPort, true); + assert.equal(error.requireWebPort, false); + assert.equal(error.baseServerPort, 13_773); + assert.equal(error.baseWebPort, 5_733); + assert.equal(error.maximumPort, 65_535); + assert.ok(!("cause" in error)); + }), + ); }); describe("checkPortAvailabilityOnHosts", () => { @@ -395,4 +470,124 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); }); + + describe("runDevRunnerWithInput", () => { + it.effect("preserves invalid configuration as the exact cause", () => + Effect.gen(function* () { + const error = yield* runDevRunnerWithInput({ ...devServerInput, dryRun: true }).pipe( + Effect.provide( + Layer.merge( + netServiceLayer, + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_PORT_OFFSET: "not-an-integer" } }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "DevRunnerConfigurationError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.deepStrictEqual(error.configKeys, ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"]); + assert.ok(error.cause !== undefined); + assert.ok(!error.message.includes(String((error.cause as Error).message))); + }), + ); + + it.effect("preserves process spawn context and the exact platform cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "vp was not found", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.mode, "dev:server"); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.ok(!error.message.includes(cause.message)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); + }); + }); + + it.effect("reports non-zero exits without manufacturing a cause", () => { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(mockProcess(17))), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessExitError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.mode, "dev:server"); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); + assert.equal(error.shell, false); + assert.equal(error.exitCode, 17); + assert.ok(!("cause" in error)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); + }); + }); + + it.effect("preserves wait-for-exit failures as the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + description: "process status became unavailable", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(mockProcess(cause))), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "wait-for-exit"); + assert.equal(error.mode, "dev:server"); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.ok(!error.message.includes(cause.message)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); + }); + }); + }); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 36c5aa41852..fb82310bbd3 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -8,7 +8,6 @@ import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Hash from "effect/Hash"; import * as Layer from "effect/Layer"; @@ -57,10 +56,87 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray { return MODE_ARGS[mode]; } -class DevRunnerError extends Data.TaggedError("DevRunnerError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class DevRunnerConfigurationError extends Schema.TaggedErrorClass()( + "DevRunnerConfigurationError", + { + configKeys: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read dev-runner configuration: ${this.configKeys.join(", ")}.`; + } +} + +export class DevRunnerInvalidPortOffsetError extends Schema.TaggedErrorClass()( + "DevRunnerInvalidPortOffsetError", + { + configKey: Schema.Literal("T3CODE_PORT_OFFSET"), + portOffset: Schema.Number, + minimum: Schema.Number, + }, +) { + override get message(): string { + return `${this.configKey} must be at least ${this.minimum}; received ${this.portOffset}.`; + } +} + +export class DevRunnerPortExhaustedError extends Schema.TaggedErrorClass()( + "DevRunnerPortExhaustedError", + { + startOffset: Schema.Number, + requireServerPort: Schema.Boolean, + requireWebPort: Schema.Boolean, + baseServerPort: Schema.Number, + baseWebPort: Schema.Number, + maximumPort: Schema.Number, + }, +) { + override get message(): string { + return `No required dev ports were available from offset ${this.startOffset} through maximum port ${this.maximumPort}.`; + } +} + +export class DevRunnerProcessError extends Schema.TaggedErrorClass()( + "DevRunnerProcessError", + { + operation: Schema.Literals(["spawn", "wait-for-exit"]), + mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + executable: Schema.Literal("vp"), + argumentCount: Schema.Number, + shell: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Dev-runner process operation "${this.operation}" failed for mode "${this.mode}".`; + } +} + +export class DevRunnerProcessExitError extends Schema.TaggedErrorClass()( + "DevRunnerProcessExitError", + { + mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + executable: Schema.Literal("vp"), + argumentCount: Schema.Number, + shell: Schema.Boolean, + exitCode: Schema.Number, + }, +) { + override get message(): string { + return `Dev-runner process exited with code ${this.exitCode} in mode "${this.mode}".`; + } +} + +export const DevRunnerError = Schema.Union([ + DevRunnerConfigurationError, + DevRunnerInvalidPortOffsetError, + DevRunnerPortExhaustedError, + DevRunnerProcessError, + DevRunnerProcessExitError, +]); +export type DevRunnerError = typeof DevRunnerError.Type; +export const isDevRunnerError = Schema.is(DevRunnerError); const optionalStringConfig = (name: string): Config.Config => Config.string(name).pipe( @@ -96,28 +172,40 @@ const OffsetConfig = Config.all({ export function resolveOffset(config: { readonly portOffset: number | undefined; readonly devInstance: string | undefined; -}): { readonly offset: number; readonly source: string } { +}): Effect.Effect< + { readonly offset: number; readonly source: string }, + DevRunnerInvalidPortOffsetError +> { if (config.portOffset !== undefined) { if (config.portOffset < 0) { - throw new Error(`Invalid T3CODE_PORT_OFFSET: ${config.portOffset}`); + return Effect.fail( + new DevRunnerInvalidPortOffsetError({ + configKey: "T3CODE_PORT_OFFSET", + portOffset: config.portOffset, + minimum: 0, + }), + ); } - return { + return Effect.succeed({ offset: config.portOffset, source: `T3CODE_PORT_OFFSET=${config.portOffset}`, - }; + }); } const seed = config.devInstance?.trim(); if (!seed) { - return { offset: 0, source: "default ports" }; + return Effect.succeed({ offset: 0, source: "default ports" }); } if (/^\d+$/.test(seed)) { - return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; + return Effect.succeed({ + offset: Number(seed), + source: `numeric T3CODE_DEV_INSTANCE=${seed}`, + }); } const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; - return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; + return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); } function resolveBaseDir(baseDir: string | undefined): Effect.Effect { @@ -275,7 +363,7 @@ export function findFirstAvailableOffset({ requireServerPort, requireWebPort, checkPortAvailability, -}: FindFirstAvailableOffsetInput): Effect.Effect { +}: FindFirstAvailableOffsetInput): Effect.Effect { return Effect.gen(function* () { const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck; @@ -311,8 +399,13 @@ export function findFirstAvailableOffset({ } } - return yield* new DevRunnerError({ - message: `No available dev ports found from offset ${startOffset}. Tried server=${BASE_SERVER_PORT}+n web=${BASE_WEB_PORT}+n up to port ${MAX_PORT}.`, + return yield* new DevRunnerPortExhaustedError({ + startOffset, + requireServerPort, + requireWebPort, + baseServerPort: BASE_SERVER_PORT, + baseWebPort: BASE_WEB_PORT, + maximumPort: MAX_PORT, }); }); } @@ -333,7 +426,7 @@ export function resolveModePortOffsets({ checkPortAvailability, }: ResolveModePortOffsetsInput): Effect.Effect< { readonly serverOffset: number; readonly webOffset: number }, - DevRunnerError, + DevRunnerPortExhaustedError, R > { return Effect.gen(function* () { @@ -397,21 +490,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const { portOffset, devInstance } = yield* OffsetConfig.pipe( Effect.mapError( (cause) => - new DevRunnerError({ - message: "Failed to read T3CODE_PORT_OFFSET/T3CODE_DEV_INSTANCE configuration.", + new DevRunnerConfigurationError({ + configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"], cause, }), ), ); - const { offset, source } = yield* Effect.try({ - try: () => resolveOffset({ portOffset, devInstance }), - catch: (cause) => - new DevRunnerError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); + const { offset, source } = yield* resolveOffset({ portOffset, devInstance }); const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, @@ -453,6 +539,12 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { [...MODE_ARGS[input.mode], ...input.runArgs], { env }, ); + const processContext = { + mode: input.mode, + executable: "vp" as const, + argumentCount: spawnCommand.args.length, + shell: spawnCommand.shell, + } as const; const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { stdin: "inherit", stdout: "inherit", @@ -465,24 +557,34 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { // which would put the runner in a new group and require manual forwarding. detached: false, forceKillAfter: "1500 millis", - }); + }).pipe( + Effect.mapError( + (cause) => + new DevRunnerProcessError({ + ...processContext, + operation: "spawn", + cause, + }), + ), + ); - const exitCode = yield* child.exitCode; + const exitCode = yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new DevRunnerProcessError({ + ...processContext, + operation: "wait-for-exit", + cause, + }), + ), + ); if (exitCode !== 0) { - return yield* new DevRunnerError({ - message: `vp run exited with code ${exitCode}`, + return yield* new DevRunnerProcessExitError({ + ...processContext, + exitCode, }); } - }).pipe( - Effect.mapError((cause) => - cause instanceof DevRunnerError - ? cause - : new DevRunnerError({ - message: cause instanceof Error ? cause.message : "dev-runner failed", - cause, - }), - ), - ); + }); } const devRunnerCli = Command.make("dev-runner", { diff --git a/scripts/lib/public-config.test.ts b/scripts/lib/public-config.test.ts index 6f6e8315664..62d383484cf 100644 --- a/scripts/lib/public-config.test.ts +++ b/scripts/lib/public-config.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Tests exercise root env file precedence directly. -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { loadRepoEnv, resolvePublicConfig } from "./public-config.ts"; @@ -10,7 +10,7 @@ const temporaryDirectories: string[] = []; afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); + NodeFS.rmSync(directory, { recursive: true, force: true }); } }); @@ -43,12 +43,12 @@ describe("loadRepoEnv", () => { it("applies process, root local, and root precedence in that order", () => { const repoRoot = makeTemporaryDirectory(); - writeFileSync( - join(repoRoot, ".env"), + NodeFS.writeFileSync( + NodePath.join(repoRoot, ".env"), "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_CLERK_JWT_TEMPLATE=template_root\nT3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauth_root\nT3CODE_RELAY_URL=https://root.example.test\n", ); - writeFileSync( - join(repoRoot, ".env.local"), + NodeFS.writeFileSync( + NodePath.join(repoRoot, ".env.local"), "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_CLERK_JWT_TEMPLATE=template_local\nT3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauth_local\nT3CODE_RELAY_URL=https://local.example.test\n", ); @@ -148,7 +148,7 @@ describe("loadRepoEnv", () => { }); function makeTemporaryDirectory() { - const directory = mkdtempSync(join(tmpdir(), "t3code-public-config-")); + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-public-config-")); temporaryDirectories.push(directory); return directory; } diff --git a/scripts/lib/resolve-catalog.test.ts b/scripts/lib/resolve-catalog.test.ts new file mode 100644 index 00000000000..ae9a2291157 --- /dev/null +++ b/scripts/lib/resolve-catalog.test.ts @@ -0,0 +1,20 @@ +import { assert, it } from "@effect/vitest"; + +import { CatalogDependencyResolutionError, resolveCatalogDependencies } from "./resolve-catalog.ts"; + +it("reports unresolved catalog dependencies with lookup context", () => { + try { + resolveCatalogDependencies({ effect: "catalog:runtime" }, {}, "apps/server"); + assert.fail("Expected catalog resolution to fail."); + } catch (error) { + assert.instanceOf(error, CatalogDependencyResolutionError); + assert.equal(error.workspacePackage, "apps/server"); + assert.equal(error.dependencyName, "effect"); + assert.equal(error.catalogSpec, "catalog:runtime"); + assert.equal(error.catalogKey, "runtime"); + assert.equal( + error.message, + "Unable to resolve 'catalog:runtime' for apps/server dependency 'effect'. Expected key 'runtime' in root workspace catalog.", + ); + } +}); diff --git a/scripts/lib/resolve-catalog.ts b/scripts/lib/resolve-catalog.ts index 597bd06c24f..eb9d4cc78c8 100644 --- a/scripts/lib/resolve-catalog.ts +++ b/scripts/lib/resolve-catalog.ts @@ -1,3 +1,19 @@ +import * as Schema from "effect/Schema"; + +export class CatalogDependencyResolutionError extends Schema.TaggedErrorClass()( + "CatalogDependencyResolutionError", + { + workspacePackage: Schema.String, + dependencyName: Schema.String, + catalogSpec: Schema.String, + catalogKey: Schema.String, + }, +) { + override get message(): string { + return `Unable to resolve '${this.catalogSpec}' for ${this.workspacePackage} dependency '${this.dependencyName}'. Expected key '${this.catalogKey}' in root workspace catalog.`; + } +} + /** * Resolve `catalog:` dependency specs using the workspace catalog. * @@ -7,7 +23,7 @@ export function resolveCatalogDependencies( dependencies: Record, catalog: Record, - label: string, + workspacePackage: string, ): Record { return Object.fromEntries( Object.entries(dependencies).map(([name, spec]) => { @@ -20,9 +36,12 @@ export function resolveCatalogDependencies( const resolved = catalog[lookupKey]; if (typeof resolved !== "string" || resolved.length === 0) { - throw new Error( - `Unable to resolve '${spec}' for ${label} dependency '${name}'. Expected key '${lookupKey}' in root workspace catalog.`, - ); + throw new CatalogDependencyResolutionError({ + workspacePackage, + dependencyName: name, + catalogSpec: spec, + catalogKey: lookupKey, + }); } return [name, resolved]; diff --git a/scripts/mobile-native-static-check.test.ts b/scripts/mobile-native-static-check.test.ts new file mode 100644 index 00000000000..7393b4bd9c8 --- /dev/null +++ b/scripts/mobile-native-static-check.test.ts @@ -0,0 +1,142 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/hostProcess"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { collectSources, runCommand } from "./mobile-native-static-check.ts"; + +const processHandle = ( + exitCode: Effect.Effect, +) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode, + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const provideSpawner = (spawn: ChildProcessSpawner.ChildProcessSpawner["Service"]["spawn"]) => + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(spawn)); + +const runSwiftLint = runCommand("swiftlint", ["lint", "--strict"], "/repo/apps/mobile").pipe( + Effect.provideService(HostProcess.HostProcessPlatform, "linux"), +); + +it.layer(NodeServices.layer)("mobile native source discovery", (it) => { + it.effect("preserves the failed discovery operation, path, and exact cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "mobile-native-static-check-" }); + const missingDirectory = path.join(root, "missing"); + + const error = yield* collectSources(missingDirectory, root).pipe(Effect.flip); + + assert.equal(error._tag, "NativeStaticCheckSourceDiscoveryError"); + assert.equal(error.operation, "read-directory"); + assert.equal(error.path, missingDirectory); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, "Native source discovery operation 'read-directory' failed."); + }), + ); +}); + +it.effect("preserves process spawn context and the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "swiftlint was not found", + }); + + return Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide(provideSpawner(() => Effect.fail(cause))), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.equal( + error.message, + "Native static check process operation 'spawn' failed for command 'swiftlint'.", + ); + assert.notProperty(error, "args"); + }); +}); + +it.effect("preserves process wait context and the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + description: "status unavailable", + }); + + return Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide(provideSpawner(() => Effect.succeed(processHandle(Effect.fail(cause))))), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "wait-for-exit"); + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.equal( + error.message, + "Native static check process operation 'wait-for-exit' failed for command 'swiftlint'.", + ); + assert.notProperty(error, "args"); + }); +}); + +it.effect("reports non-zero exits without manufacturing a cause", () => + Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide( + provideSpawner(() => + Effect.succeed(processHandle(Effect.succeed(ChildProcessSpawner.ExitCode(2)))), + ), + ), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckCommandError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.exitCode, 2); + assert.notProperty(error, "cause"); + assert.notProperty(error, "args"); + }), +); diff --git a/scripts/mobile-native-static-check.ts b/scripts/mobile-native-static-check.ts index 4b43788a9ef..8239a092bc2 100644 --- a/scripts/mobile-native-static-check.ts +++ b/scripts/mobile-native-static-check.ts @@ -4,12 +4,11 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import { Command } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -18,9 +17,51 @@ interface NativeStaticTool { readonly installHint: string; } -class NativeStaticCheckError extends Data.TaggedError("NativeStaticCheckError")<{ - readonly message: string; -}> {} +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +export class NativeStaticCheckSourceDiscoveryError extends Schema.TaggedErrorClass()( + "NativeStaticCheckSourceDiscoveryError", + { + operation: Schema.Literals(["resolve-root", "read-directory", "stat-entry"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Native source discovery operation '${this.operation}' failed.`; + } +} + +export class NativeStaticCheckProcessError extends Schema.TaggedErrorClass()( + "NativeStaticCheckProcessError", + { + operation: Schema.Literals(["spawn", "wait-for-exit"]), + command: Schema.String, + argumentCount: NonNegativeInt, + cwd: Schema.String, + shell: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Native static check process operation '${this.operation}' failed for command '${this.command}'.`; + } +} + +export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass()( + "NativeStaticCheckCommandError", + { + command: Schema.String, + argumentCount: NonNegativeInt, + cwd: Schema.String, + shell: Schema.Boolean, + exitCode: Schema.Int, + }, +) { + override get message(): string { + return `Native static check command '${this.command}' exited with code ${this.exitCode}.`; + } +} const tools = [ { @@ -49,8 +90,17 @@ const excludedDirectories = new Set([ ]); const generatedNativeProjectDirectories = new Set(["android", "ios"]); +const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url); const appRoot = Effect.service(Path.Path).pipe( - Effect.flatMap((path) => path.fromFileUrl(new URL("../apps/mobile", import.meta.url))), + Effect.flatMap((path) => path.fromFileUrl(mobileAppRootUrl)), + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "resolve-root", + path: mobileAppRootUrl.pathname, + cause, + }), + ), ); const commandOutputOptions = { @@ -67,7 +117,7 @@ const warnMissingTool = (tool: NativeStaticTool, checkName: string) => `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`, ); -const runCommand = Effect.fn("runCommand")(function* ( +export const runCommand = Effect.fn("runCommand")(function* ( command: string, args: ReadonlyArray, cwd: string, @@ -75,39 +125,86 @@ const runCommand = Effect.fn("runCommand")(function* ( yield* Console.log(`$ ${[command, ...args].join(" ")}`); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const spawnCommand = yield* resolveSpawnCommand(command, args); - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd, - ...commandOutputOptions, - shell: spawnCommand.shell, - }), + const processContext = { + command, + argumentCount: spawnCommand.args.length, + cwd, + shell: spawnCommand.shell, + } as const; + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd, + ...commandOutputOptions, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckProcessError({ + ...processContext, + operation: "spawn", + cause, + }), + ), + ); + const exitCode = Number( + yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckProcessError({ + ...processContext, + operation: "wait-for-exit", + cause, + }), + ), + ), ); - const exitCode = Number(yield* child.exitCode); if (exitCode !== 0) { - return yield* new NativeStaticCheckError({ - message: `Command exited with non-zero exit code (${exitCode})`, + return yield* new NativeStaticCheckCommandError({ + ...processContext, + exitCode, }); } }); -function collectSources( +export function collectSources( directory: string, root: string, ): Effect.Effect< ReadonlyArray, - PlatformError.PlatformError, + NativeStaticCheckSourceDiscoveryError, FileSystem.FileSystem | Path.Path > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const entries = yield* fs.readDirectory(directory); + const entries = yield* fs.readDirectory(directory).pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "read-directory", + path: directory, + cause, + }), + ), + ); const sources: Array = []; for (const entry of entries) { const entryPath = path.join(directory, entry); - const stat = yield* fs.stat(entryPath); + const stat = yield* fs.stat(entryPath).pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "stat-entry", + path: entryPath, + cause, + }), + ), + ); if (stat.type === "Directory") { const isGeneratedNativeProjectDirectory = diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index 23f73fa0d76..7ca108f3188 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -1,6 +1,25 @@ import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; -import { buildDiscordReleaseAnnouncement } from "./notify-discord-release.ts"; +import { + buildDiscordReleaseAnnouncement, + isDiscordReleaseAnnouncementError, + postDiscordWebhook, +} from "./notify-discord-release.ts"; + +const latestAnnouncement = { + target: "latest", + roleId: "222222222222222222", + releaseName: "T3 Code v1.2.3", + version: "1.2.3", + tag: "v1.2.3", + releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), + timestamp: "2026-05-01T01:41:00.000Z", +} as const; + +const webhookUrl = new URL("https://discord.com/api/webhooks/123456/secret-token"); it("builds a prerelease Discord announcement for nightly subscribers", () => { assert.deepStrictEqual( @@ -47,42 +66,109 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => { }); it("builds a latest Discord announcement for stable subscribers", () => { - assert.deepStrictEqual( - buildDiscordReleaseAnnouncement({ - target: "latest", - roleId: "222222222222222222", - releaseName: "T3 Code v1.2.3", - version: "1.2.3", - tag: "v1.2.3", - releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), - timestamp: "2026-05-01T01:41:00.000Z", - }), - { - content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", - allowed_mentions: { - roles: ["222222222222222222"], - }, - embeds: [ - { - title: "T3 Code v1.2.3", - url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", - description: "A new T3 Code latest release is available.", - color: 0x2ecc71, - fields: [ - { - name: "Version", - value: "1.2.3", - inline: true, - }, - { - name: "Tag", - value: "v1.2.3", - inline: true, - }, - ], - timestamp: "2026-05-01T01:41:00.000Z", - }, - ], + assert.deepStrictEqual(buildDiscordReleaseAnnouncement(latestAnnouncement), { + content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", + allowed_mentions: { + roles: ["222222222222222222"], }, + embeds: [ + { + title: "T3 Code v1.2.3", + url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", + description: "A new T3 Code latest release is available.", + color: 0x2ecc71, + fields: [ + { + name: "Version", + value: "1.2.3", + inline: true, + }, + { + name: "Tag", + value: "v1.2.3", + inline: true, + }, + ], + timestamp: "2026-05-01T01:41:00.000Z", + }, + ], + }); +}); + +it.effect("preserves webhook request context and the full client cause chain", () => { + const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); + const requestCause = new Error("request encoder unavailable"); + let clientError: HttpClientError.HttpClientError | undefined; + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + clientError = new HttpClientError.HttpClientError({ + reason: new HttpClientError.EncodeError({ + request, + cause: requestCause, + }), + }); + return Effect.fail(clientError); + }), ); + + return Effect.gen(function* () { + const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( + Effect.provide(httpClientLayer), + Effect.flip, + ); + + if (error._tag !== "DiscordReleaseWebhookRequestError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.target, "latest"); + assert.equal(error.releaseName, latestAnnouncement.releaseName); + assert.equal(error.version, latestAnnouncement.version); + assert.equal(error.tag, latestAnnouncement.tag); + assert.equal(error.releaseUrl, latestAnnouncement.releaseUrl.href); + assert.equal(error.webhookOrigin, webhookUrl.origin); + assert.equal(error.webhookPathnameSegmentCount, 4); + assert.equal(error.contentLength, payload.content.length); + assert.equal(error.embedCount, 1); + assert.equal(error.allowedRoleMentionCount, 1); + assert.equal(error.hasRoleMentionSyntax, true); + assert.equal(error.cause, clientError); + assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause); + assert.ok(!error.message.includes(requestCause.message)); + assert.equal(isDiscordReleaseAnnouncementError(error), true); + }); +}); + +it.effect("preserves a non-success response error with structured status context", () => { + const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("invalid webhook", { status: 400 })), + ), + ), + ); + + return Effect.gen(function* () { + const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( + Effect.provide(httpClientLayer), + Effect.flip, + ); + + if (error._tag !== "DiscordReleaseWebhookResponseError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.target, "latest"); + assert.equal(error.tag, latestAnnouncement.tag); + assert.equal(error.webhookOrigin, webhookUrl.origin); + assert.equal(error.webhookPathnameSegmentCount, 4); + assert.equal(error.status, 400); + if (!HttpClientError.isHttpClientError(error.cause)) { + assert.fail("Expected HttpClientError cause"); + } + assert.equal(error.cause.reason._tag, "StatusCodeError"); + assert.ok(!error.message.includes(error.cause.message)); + assert.equal(isDiscordReleaseAnnouncementError(error), true); + }); }); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 1e3106b05ed..d013e7c2f65 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -3,7 +3,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -19,7 +18,7 @@ import { export type DiscordReleaseTarget = "prerelease" | "latest"; -interface DiscordReleaseAnnouncementOptions { +export interface DiscordReleaseAnnouncementOptions { readonly target: DiscordReleaseTarget; readonly roleId: string; readonly releaseName: string; @@ -52,10 +51,51 @@ const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); -class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const discordReleaseErrorContext = { + target: Schema.Literals(["prerelease", "latest"]), + releaseName: Schema.String, + version: Schema.String, + tag: Schema.String, + releaseUrl: Schema.String, + webhookOrigin: Schema.String, + webhookPathnameSegmentCount: Schema.Number, + contentLength: Schema.Number, + embedCount: Schema.Number, + allowedRoleMentionCount: Schema.Number, + hasRoleMentionSyntax: Schema.Boolean, +}; + +export class DiscordReleaseWebhookRequestError extends Schema.TaggedErrorClass()( + "DiscordReleaseWebhookRequestError", + { + ...discordReleaseErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to post Discord ${this.target} release announcement for "${this.tag}" to ${this.webhookOrigin}.`; + } +} + +export class DiscordReleaseWebhookResponseError extends Schema.TaggedErrorClass()( + "DiscordReleaseWebhookResponseError", + { + ...discordReleaseErrorContext, + status: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Discord ${this.target} release webhook for "${this.tag}" returned status ${this.status}.`; + } +} + +export const DiscordReleaseAnnouncementError = Schema.Union([ + DiscordReleaseWebhookRequestError, + DiscordReleaseWebhookResponseError, +]); +export type DiscordReleaseAnnouncementError = typeof DiscordReleaseAnnouncementError.Type; +export const isDiscordReleaseAnnouncementError = Schema.is(DiscordReleaseAnnouncementError); const targetLabels = { prerelease: "Prerelease", @@ -117,9 +157,10 @@ export const buildDiscordReleaseAnnouncement = ( ], }); -const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( +export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( webhookUrl: URL, payload: DiscordWebhookPayload, + announcement: DiscordReleaseAnnouncementOptions, ) { const httpClient = (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -135,13 +176,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( }), ); + const errorContext = { + target: announcement.target, + releaseName: announcement.releaseName, + version: announcement.version, + tag: announcement.tag, + releaseUrl: announcement.releaseUrl.href, + webhookOrigin: webhookUrl.origin, + webhookPathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length, + ...summarizePayload(payload), + } as const; + const response = yield* HttpClientRequest.post(webhookUrl).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => - new DiscordReleaseAnnouncementError({ - message: "Failed to post Discord release announcement.", + new DiscordReleaseWebhookRequestError({ + ...errorContext, cause, }), ), @@ -157,8 +209,9 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.mapError( (cause) => - new DiscordReleaseAnnouncementError({ - message: `Discord webhook returned status ${response.status}.`, + new DiscordReleaseWebhookResponseError({ + ...errorContext, + status: response.status, cause, }), ), @@ -208,7 +261,7 @@ export const notifyDiscordReleaseCommand = Command.make( const webhookUrl = yield* DiscordWebhookUrl; const timestamp = DateTime.formatIso(yield* DateTime.now); - const payload = buildDiscordReleaseAnnouncement({ + const announcement = { target, roleId, releaseName, @@ -216,12 +269,13 @@ export const notifyDiscordReleaseCommand = Command.make( tag, releaseUrl, timestamp, - }); + } satisfies DiscordReleaseAnnouncementOptions; + const payload = buildDiscordReleaseAnnouncement(announcement); yield* Effect.logInfo("discord release announcement payload built").pipe( Effect.annotateLogs(summarizePayload(payload)), ); - yield* postDiscordWebhook(webhookUrl, payload); + yield* postDiscordWebhook(webhookUrl, payload, announcement); yield* Effect.logInfo("discord release announcement completed"); }), ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 2fe67164666..45d2dd436b6 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -1,21 +1,13 @@ // @effect-diagnostics nodeBuiltinImport:off -import { execFileSync } from "node:child_process"; -import { - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); const workspaceFiles = [ "package.json", @@ -44,26 +36,26 @@ const workspaceFiles = [ function copyWorkspaceManifestFixture(targetRoot: string): void { for (const relativePath of workspaceFiles) { - const sourcePath = resolve(repoRoot, relativePath); - const destinationPath = resolve(targetRoot, relativePath); - mkdirSync(dirname(destinationPath), { recursive: true }); - cpSync(sourcePath, destinationPath); + const sourcePath = NodePath.resolve(repoRoot, relativePath); + const destinationPath = NodePath.resolve(targetRoot, relativePath); + NodeFS.mkdirSync(NodePath.dirname(destinationPath), { recursive: true }); + NodeFS.cpSync(sourcePath, destinationPath); } - const patchesDirectory = resolve(repoRoot, "patches"); - if (existsSync(patchesDirectory)) { - cpSync(patchesDirectory, resolve(targetRoot, "patches"), { recursive: true }); + const patchesDirectory = NodePath.resolve(repoRoot, "patches"); + if (NodeFS.existsSync(patchesDirectory)) { + NodeFS.cpSync(patchesDirectory, NodePath.resolve(targetRoot, "patches"), { recursive: true }); } } function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { - const assetDirectory = resolve(targetRoot, "release-assets"); - mkdirSync(assetDirectory, { recursive: true }); + const assetDirectory = NodePath.resolve(targetRoot, "release-assets"); + NodeFS.mkdirSync(assetDirectory, { recursive: true }); - const arm64Path = resolve(assetDirectory, "latest-mac.yml"); - const x64Path = resolve(assetDirectory, "latest-mac-x64.yml"); + const arm64Path = NodePath.resolve(assetDirectory, "latest-mac.yml"); + const x64Path = NodePath.resolve(assetDirectory, "latest-mac-x64.yml"); - writeFileSync( + NodeFS.writeFileSync( arm64Path, `version: 9.9.9-smoke.0 files: @@ -79,7 +71,7 @@ releaseDate: '2026-03-08T10:32:14.587Z' `, ); - writeFileSync( + NodeFS.writeFileSync( x64Path, `version: 9.9.9-smoke.0 files: @@ -102,13 +94,13 @@ function writeWindowsManifestFixtures( targetRoot: string, channel: string, ): { arm64Path: string; x64Path: string } { - const assetDirectory = resolve(targetRoot, "release-assets"); - mkdirSync(assetDirectory, { recursive: true }); + const assetDirectory = NodePath.resolve(targetRoot, "release-assets"); + NodeFS.mkdirSync(assetDirectory, { recursive: true }); - const arm64Path = resolve(assetDirectory, `${channel}-win-arm64.yml`); - const x64Path = resolve(assetDirectory, `${channel}-win-x64.yml`); + const arm64Path = NodePath.resolve(assetDirectory, `${channel}-win-arm64.yml`); + const x64Path = NodePath.resolve(assetDirectory, `${channel}-win-x64.yml`); - writeFileSync( + NodeFS.writeFileSync( arm64Path, `version: 9.9.9-smoke.0 files: @@ -124,7 +116,7 @@ releaseDate: '2026-03-08T10:32:14.587Z' `, ); - writeFileSync( + NodeFS.writeFileSync( x64Path, `version: 9.9.9-smoke.0 files: @@ -147,11 +139,11 @@ function writeWindowsBuilderDebugFixtures(targetRoot: string): { arm64Path: string; x64Path: string; } { - const assetDirectory = resolve(targetRoot, "release-assets"); - mkdirSync(assetDirectory, { recursive: true }); + const assetDirectory = NodePath.resolve(targetRoot, "release-assets"); + NodeFS.mkdirSync(assetDirectory, { recursive: true }); - const arm64Path = resolve(assetDirectory, "builder-debug-win-arm64.yml"); - const x64Path = resolve(assetDirectory, "builder-debug-win-x64.yml"); + const arm64Path = NodePath.resolve(assetDirectory, "builder-debug-win-arm64.yml"); + const x64Path = NodePath.resolve(assetDirectory, "builder-debug-win-x64.yml"); const debugFixture = `arm64: firstOrDefaultFilePatterns: - '**/*' @@ -160,8 +152,8 @@ nsis: !include "example.nsh" `; - writeFileSync(arm64Path, debugFixture); - writeFileSync(x64Path, debugFixture); + NodeFS.writeFileSync(arm64Path, debugFixture); + NodeFS.writeFileSync(x64Path, debugFixture); return { arm64Path, x64Path }; } @@ -172,13 +164,13 @@ function assertContains(haystack: string, needle: string, message: string): void } function assertExists(path: string, message: string): void { - if (!existsSync(path)) { + if (!NodeFS.existsSync(path)) { throw new Error(message); } } function assertPackageVersion(path: string, version: string): void { - const packageJson = JSON.parse(readFileSync(path, "utf8")) as { + const packageJson = JSON.parse(NodeFS.readFileSync(path, "utf8")) as { readonly version?: unknown; }; @@ -188,20 +180,20 @@ function assertPackageVersion(path: string, version: string): void { } function assertMissing(path: string, message: string): void { - if (existsSync(path)) { + if (NodeFS.existsSync(path)) { throw new Error(message); } } -const tempRoot = mkdtempSync(join(tmpdir(), "t3-release-smoke-")); +const tempRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-release-smoke-")); try { copyWorkspaceManifestFixture(tempRoot); - execFileSync( + NodeChildProcess.execFileSync( process.execPath, [ - resolve(repoRoot, "scripts/update-release-package-versions.ts"), + NodePath.resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", "--root", tempRoot, @@ -212,14 +204,14 @@ try { }, ); - rmSync(resolve(tempRoot, "pnpm-lock.yaml"), { force: true }); + NodeFS.rmSync(NodePath.resolve(tempRoot, "pnpm-lock.yaml"), { force: true }); - execFileSync("vp", ["install", "--lockfile-only", "--ignore-scripts"], { + NodeChildProcess.execFileSync("vp", ["install", "--lockfile-only", "--ignore-scripts"], { cwd: tempRoot, stdio: "inherit", }); - const lockfile = readFileSync(resolve(tempRoot, "pnpm-lock.yaml"), "utf8"); + const lockfile = NodeFS.readFileSync(NodePath.resolve(tempRoot, "pnpm-lock.yaml"), "utf8"); assertContains(lockfile, "lockfileVersion:", "Expected pnpm-lock.yaml to be regenerated."); for (const relativePath of [ @@ -228,13 +220,13 @@ try { "apps/web/package.json", "packages/contracts/package.json", ]) { - assertPackageVersion(resolve(tempRoot, relativePath), "9.9.9-smoke.0"); + assertPackageVersion(NodePath.resolve(tempRoot, relativePath), "9.9.9-smoke.0"); } - const nightlyReleaseMetadata = execFileSync( + const nightlyReleaseMetadata = NodeChildProcess.execFileSync( process.execPath, [ - resolve(repoRoot, "scripts/resolve-nightly-release.ts"), + NodePath.resolve(repoRoot, "scripts/resolve-nightly-release.ts"), "--date", "20260413", "--run-number", @@ -266,10 +258,10 @@ try { ); const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); - execFileSync( + NodeChildProcess.execFileSync( process.execPath, [ - resolve(repoRoot, "scripts/merge-update-manifests.ts"), + NodePath.resolve(repoRoot, "scripts/merge-update-manifests.ts"), "--platform", "mac", arm64Path, @@ -281,7 +273,7 @@ try { }, ); - const mergedManifest = readFileSync(arm64Path, "utf8"); + const mergedManifest = NodeFS.readFileSync(arm64Path, "utf8"); assertContains( mergedManifest, "T3-Code-9.9.9-smoke.0-arm64.zip", @@ -297,21 +289,21 @@ try { tempRoot, "latest", ); - const mergedWindowsManifestPath = resolve(tempRoot, "release-assets/latest.yml"); + const mergedWindowsManifestPath = NodePath.resolve(tempRoot, "release-assets/latest.yml"); const { arm64Path: nightlyWinArm64Path, x64Path: nightlyWinX64Path } = writeWindowsManifestFixtures(tempRoot, "nightly"); - const mergedNightlyWindowsManifestPath = resolve(tempRoot, "release-assets/nightly.yml"); + const mergedNightlyWindowsManifestPath = NodePath.resolve(tempRoot, "release-assets/nightly.yml"); const { arm64Path: previewWinArm64Path, x64Path: previewWinX64Path } = writeWindowsManifestFixtures(tempRoot, "preview"); - const mergedPreviewWindowsManifestPath = resolve(tempRoot, "release-assets/preview.yml"); + const mergedPreviewWindowsManifestPath = NodePath.resolve(tempRoot, "release-assets/preview.yml"); const { arm64Path: winDebugArm64Path, x64Path: winDebugX64Path } = writeWindowsBuilderDebugFixtures(tempRoot); - execFileSync( + NodeChildProcess.execFileSync( "bash", [ "-lc", ` - release_assets_dir=${JSON.stringify(resolve(tempRoot, "release-assets"))} + release_assets_dir=${JSON.stringify(NodePath.resolve(tempRoot, "release-assets"))} shopt -s nullglob found_windows_manifest=false for x64_manifest in "$release_assets_dir"/*-win-x64.yml; do @@ -327,7 +319,7 @@ try { fi found_windows_manifest=true - ${JSON.stringify(process.execPath)} ${JSON.stringify(resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ + ${JSON.stringify(process.execPath)} ${JSON.stringify(NodePath.resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ "$arm64_manifest" \ "$x64_manifest" \ "$output_manifest" @@ -346,7 +338,7 @@ try { }, ); - const mergedWindowsManifest = readFileSync(mergedWindowsManifestPath, "utf8"); + const mergedWindowsManifest = NodeFS.readFileSync(mergedWindowsManifestPath, "utf8"); assertContains( mergedWindowsManifest, "T3-Code-9.9.9-smoke.0-arm64.exe", @@ -357,7 +349,10 @@ try { "T3-Code-9.9.9-smoke.0-x64.exe", "Merged Windows manifest is missing the x64 asset.", ); - const mergedNightlyWindowsManifest = readFileSync(mergedNightlyWindowsManifestPath, "utf8"); + const mergedNightlyWindowsManifest = NodeFS.readFileSync( + mergedNightlyWindowsManifestPath, + "utf8", + ); assertContains( mergedNightlyWindowsManifest, "T3-Code-9.9.9-smoke.0-arm64.exe", @@ -368,7 +363,10 @@ try { "T3-Code-9.9.9-smoke.0-x64.exe", "Merged nightly Windows manifest is missing the x64 asset.", ); - const mergedPreviewWindowsManifest = readFileSync(mergedPreviewWindowsManifestPath, "utf8"); + const mergedPreviewWindowsManifest = NodeFS.readFileSync( + mergedPreviewWindowsManifestPath, + "utf8", + ); assertContains( mergedPreviewWindowsManifest, "T3-Code-9.9.9-smoke.0-arm64.exe", @@ -411,5 +409,5 @@ try { Effect.runSync(Console.log("Release smoke checks passed.")); } finally { - rmSync(tempRoot, { recursive: true, force: true }); + NodeFS.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index 82b25737a58..dda1e7081be 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -1,9 +1,18 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { + readDesktopBaseVersion, resolveNightlyBaseVersion, resolveNightlyReleaseMetadata, resolveNightlyTargetVersion, + writeNightlyReleaseOutput, } from "./resolve-nightly-release.ts"; it("strips prerelease and build metadata when deriving the nightly base version", () => { @@ -12,11 +21,23 @@ it("strips prerelease and build metadata when deriving the nightly base version" assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3"); }); -it("bumps the patch version before deriving nightly prerelease versions", () => { - assert.equal(resolveNightlyTargetVersion("0.0.17"), "0.0.18"); - assert.equal(resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10"); - assert.equal(resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4"); -}); +it.effect("bumps the patch version before deriving nightly prerelease versions", () => + Effect.gen(function* () { + assert.equal(yield* resolveNightlyTargetVersion("0.0.17"), "0.0.18"); + assert.equal(yield* resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10"); + assert.equal(yield* resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4"); + }), +); + +it.effect("reports the invalid desktop package version", () => + Effect.gen(function* () { + const error = yield* resolveNightlyTargetVersion("nightly").pipe(Effect.flip); + + assert.equal(error._tag, "InvalidDesktopPackageVersionError"); + assert.equal(error.version, "nightly"); + assert.equal(error.message, "Invalid desktop package version 'nightly'."); + }), +); it("derives nightly metadata including the short commit sha in the release name", () => { assert.deepStrictEqual( @@ -30,3 +51,72 @@ it("derives nightly metadata including the short commit sha in the release name" }, ); }); + +it.effect("preserves the GITHUB_OUTPUT configuration cause", () => { + const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890"); + const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" }); + + return Effect.gen(function* () { + const configError = yield* writeNightlyReleaseOutput(metadata, true).pipe( + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.make(() => Effect.fail(configCause)), + ), + Effect.flip, + ); + + if (configError._tag !== "NightlyReleaseGitHubOutputConfigError") { + return assert.fail(`Unexpected error: ${configError._tag}`); + } + assert.instanceOf(configError.cause, Config.ConfigError); + assert.strictEqual(configError.cause.cause, configCause); + assert.notInclude(configError.message, configCause.message); + }); +}); + +it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => { + it.effect("preserves desktop package read context and its platform cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "resolve-nightly-release-read-", + }); + const packageJsonPath = path.join(rootDir, "apps/desktop/package.json"); + + const error = yield* readDesktopBaseVersion(rootDir).pipe(Effect.flip); + + if (error._tag !== "NightlyReleaseDesktopPackageError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "read"); + assert.equal(error.packageJsonPath, packageJsonPath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.notInclude(error.message, String((error.cause as Error).message)); + }), + ); + + it.effect("preserves desktop package decode context and its schema cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "resolve-nightly-release-decode-", + }); + const packageJsonPath = path.join(rootDir, "apps/desktop/package.json"); + yield* fs.makeDirectory(path.dirname(packageJsonPath), { recursive: true }); + yield* fs.writeFileString(packageJsonPath, "{"); + + const error = yield* readDesktopBaseVersion(rootDir).pipe(Effect.flip); + + if (error._tag !== "NightlyReleaseDesktopPackageError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "decode"); + assert.equal(error.packageJsonPath, packageJsonPath); + assert.ok(error.cause !== undefined); + assert.notInclude(error.message, String((error.cause as Error).message)); + }), + ); +}); diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index e3f064305bf..5b42f931d7b 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -11,7 +11,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; -interface NightlyReleaseMetadata { +export interface NightlyReleaseMetadata { readonly baseVersion: string; readonly version: string; readonly tag: string; @@ -29,6 +29,53 @@ const DesktopPackageJsonSchema = Schema.Struct({ version: Schema.NonEmptyString, }); +export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass()( + "InvalidDesktopPackageVersionError", + { + version: Schema.String, + }, +) { + override get message(): string { + return `Invalid desktop package version '${this.version}'.`; + } +} + +export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass()( + "NightlyReleaseDesktopPackageError", + { + operation: Schema.Literals(["read", "decode"]), + packageJsonPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} desktop package metadata at ${this.packageJsonPath}.`; + } +} + +export class NightlyReleaseGitHubOutputConfigError extends Schema.TaggedErrorClass()( + "NightlyReleaseGitHubOutputConfigError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve the GITHUB_OUTPUT path for nightly release metadata."; + } +} + +export class NightlyReleaseGitHubOutputAppendError extends Schema.TaggedErrorClass()( + "NightlyReleaseGitHubOutputAppendError", + { + outputPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append nightly release metadata to ${this.outputPath}.`; + } +} + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -42,11 +89,11 @@ export const resolveNightlyTargetVersion = (version: string) => { const stableCore = resolveNightlyBaseVersion(version); const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore); if (!match) { - throw new Error(`Invalid desktop package version '${version}'.`); + return Effect.fail(new InvalidDesktopPackageVersionError({ version })); } const [, major, minor, patch] = match; - return `${major}.${minor}.${Number(patch) + 1}`; + return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`); }; export const resolveNightlyReleaseMetadata = ( @@ -66,20 +113,37 @@ export const resolveNightlyReleaseMetadata = ( }; }; -const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( +export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( rootDir: string | undefined, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot; const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json"); - const packageJson = yield* fs - .readFileString(packageJsonPath) - .pipe(Effect.flatMap(decodeDesktopPackageJson)); - return resolveNightlyTargetVersion(packageJson.version); + const packageJsonSource = yield* fs.readFileString(packageJsonPath).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseDesktopPackageError({ + operation: "read", + packageJsonPath, + cause, + }), + ), + ); + const packageJson = yield* decodeDesktopPackageJson(packageJsonSource).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseDesktopPackageError({ + operation: "decode", + packageJsonPath, + cause, + }), + ), + ); + return yield* resolveNightlyTargetVersion(packageJson.version); }); -const writeOutput = Effect.fn("writeOutput")(function* ( +export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* ( metadata: NightlyReleaseMetadata, writeGithubOutput: boolean, ) { @@ -94,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* ( ] as const; if (writeGithubOutput) { - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new NightlyReleaseGitHubOutputConfigError({ + cause, + }), + ), + ); const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join(""); - yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }); + yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseGitHubOutputAppendError({ + outputPath: githubOutputPath, + cause, + }), + ), + ); } else { for (const [key, value] of entries) { yield* Console.log(`${key}=${value}`); @@ -131,7 +210,7 @@ const command = Command.make( ({ date, runNumber, sha, githubOutput, root }) => readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), - Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)), + Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)), ), ).pipe(Command.withDescription("Resolve nightly release version metadata.")); diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts new file mode 100644 index 00000000000..5fe06d2af54 --- /dev/null +++ b/scripts/resolve-previous-release-tag.test.ts @@ -0,0 +1,213 @@ +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + listGitTags, + resolvePreviousReleaseTag, + writePreviousReleaseTagOutput, +} from "./resolve-previous-release-tag.ts"; + +const encoder = new TextEncoder(); + +function mockHandle(options: { + readonly exitCode: number; + readonly stdout?: string; + readonly stderr?: string; + readonly stdoutError?: PlatformError.PlatformError; + readonly stderrError?: PlatformError.PlatformError; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: options.stdoutError + ? Stream.fail(options.stdoutError) + : Stream.make(encoder.encode(options.stdout ?? "")), + stderr: options.stderrError + ? Stream.fail(options.stderrError) + : Stream.make(encoder.encode(options.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +it.effect("selects the latest earlier stable tag and ignores nightlies", () => + Effect.gen(function* () { + const previous = yield* resolvePreviousReleaseTag("stable", "v1.2.0", [ + "v1.1.0", + "v1.1.1-nightly.20260619.1", + "v1.1.2", + "v1.2.0", + ]); + + assert.equal(previous, "v1.1.2"); + }), +); + +it.effect("accepts legacy nightly tags when selecting the previous nightly", () => + Effect.gen(function* () { + const previous = yield* resolvePreviousReleaseTag("nightly", "v1.2.0-nightly.20260620.2", [ + "nightly-v1.2.0-nightly.20260620.1", + "v1.1.0-nightly.20260619.9", + ]); + + assert.equal(previous, "nightly-v1.2.0-nightly.20260620.1"); + }), +); + +it.effect("reports the invalid tag with its release channel", () => + Effect.gen(function* () { + const error = yield* resolvePreviousReleaseTag("nightly", "v1.2.0", []).pipe(Effect.flip); + + assert.equal(error._tag, "InvalidReleaseTagError"); + assert.equal(error.channel, "nightly"); + assert.equal(error.currentTag, "v1.2.0"); + assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'."); + }), +); + +it.effect("preserves git tag spawn context and the exact platform cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "git was not found", + }); + + return Effect.gen(function* () { + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.executable, "git"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.notProperty(error, "args"); + assert.notInclude(error.message, cause.message); + }); +}); + +it.effect("distinguishes stdout and stderr read failures", () => + Effect.gen(function* () { + for (const [stream, operation] of [ + ["stdout", "read-stdout"], + ["stderr", "read-stderr"], + ] as const) { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: stream, + description: `${stream} unavailable`, + }); + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + exitCode: 0, + ...(stream === "stdout" ? { stdoutError: cause } : { stderrError: cause }), + }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, operation); + assert.strictEqual(error.cause, cause); + } + }), +); + +it.effect("reports git tag non-zero exits without manufacturing a cause", () => + Effect.gen(function* () { + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + exitCode: 17, + stdout: "v1.2.3\n", + stderr: "fatal: repository unavailable\n", + }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessExitError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.executable, "git"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo"); + assert.equal(error.exitCode, 17); + assert.equal(error.stdoutLength, 7); + assert.equal(error.stderrLength, 30); + assert.notProperty(error, "cause"); + assert.notProperty(error, "stdout"); + assert.notProperty(error, "stderr"); + }), +); + +it.effect("preserves the GITHUB_OUTPUT append path and exact cause", () => { + const outputPath = "/tmp/previous-tag-github-output"; + const appendCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + pathOrDescriptor: outputPath, + }); + + return Effect.gen(function* () { + const appendError = yield* writePreviousReleaseTagOutput("v1.2.3", true).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: () => Effect.fail(appendCause), + }), + ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env: { GITHUB_OUTPUT: outputPath } }), + ), + Effect.flip, + ); + + if (appendError._tag !== "PreviousReleaseTagGitHubOutputAppendError") { + return assert.fail(`Unexpected error: ${appendError._tag}`); + } + assert.equal(appendError.outputPath, outputPath); + assert.strictEqual(appendError.cause, appendCause); + assert.notProperty(appendError, "contents"); + assert.notInclude(appendError.message, appendCause.message); + }); +}); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index dc7a7794da8..dfb09b0cf7d 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -2,7 +2,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Array from "effect/Array"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -15,6 +14,76 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const ReleaseChannel = Schema.Literals(["stable", "nightly"]); type ReleaseChannel = typeof ReleaseChannel.Type; +export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( + "InvalidReleaseTagError", + { + channel: ReleaseChannel, + currentTag: Schema.String, + }, +) { + override get message(): string { + return `Invalid ${this.channel} release tag '${this.currentTag}'.`; + } +} + +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const releaseTagListProcessContext = { + executable: Schema.Literal("git"), + argumentCount: NonNegativeInt, + cwd: Schema.String, +}; + +export class ReleaseTagListProcessError extends Schema.TaggedErrorClass()( + "ReleaseTagListProcessError", + { + ...releaseTagListProcessContext, + operation: Schema.Literals(["spawn", "read-stdout", "read-stderr", "wait-for-exit"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to list release tags during process operation "${this.operation}".`; + } +} + +export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass()( + "ReleaseTagListProcessExitError", + { + ...releaseTagListProcessContext, + exitCode: Schema.Number, + stdoutLength: NonNegativeInt, + stderrLength: NonNegativeInt, + }, +) { + override get message(): string { + return `Release tag listing exited with code ${this.exitCode}.`; + } +} + +export class PreviousReleaseTagGitHubOutputConfigError extends Schema.TaggedErrorClass()( + "PreviousReleaseTagGitHubOutputConfigError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve the GITHUB_OUTPUT path for the previous release tag."; + } +} + +export class PreviousReleaseTagGitHubOutputAppendError extends Schema.TaggedErrorClass()( + "PreviousReleaseTagGitHubOutputAppendError", + { + outputPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append the previous release tag to ${this.outputPath}.`; + } +} + interface StableVersion { readonly major: number; readonly minor: number; @@ -124,59 +193,122 @@ const parseNightlyTag = (tag: string): NightlyVersion | undefined => { }; }; -const resolvePreviousReleaseTag = ( +export const resolvePreviousReleaseTag = ( channel: ReleaseChannel, currentTag: string, tags: ReadonlyArray, -): string | undefined => { - if (channel === "stable") { - const current = parseStableTag(currentTag); +) => + Effect.gen(function* () { + if (channel === "stable") { + const current = parseStableTag(currentTag); + if (!current) { + return yield* new InvalidReleaseTagError({ channel, currentTag }); + } + + const candidates = tags + .map((tag) => ({ tag, parsed: parseStableTag(tag) })) + .filter( + (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, + ) + .filter((entry) => compareStableVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); + + return candidates[0]?.tag; + } + + const current = parseNightlyTag(currentTag); if (!current) { - throw new Error(`Invalid stable release tag '${currentTag}'.`); + return yield* new InvalidReleaseTagError({ channel, currentTag }); } const candidates = tags - .map((tag) => ({ tag, parsed: parseStableTag(tag) })) + .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) .filter( - (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, + (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined, ) - .filter((entry) => compareStableVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); + .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); return candidates[0]?.tag; - } - - const current = parseNightlyTag(currentTag); - if (!current) { - throw new Error(`Invalid nightly release tag '${currentTag}'.`); - } - - const candidates = tags - .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) - .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined) - .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); + }); - return candidates[0]?.tag; -}; - -const listGitTags = Effect.fn("listGitTags")(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"])); - const tags = yield* child.stdout.pipe( +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), - Effect.map(String.split(/\r?\n/)), - Effect.map(Array.map(String.trim)), - Effect.map(Array.filter(String.isNonEmpty)), ); - return tags; + +export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd()) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const args = ["tag", "--list"] as const; + const context = { + executable: "git", + argumentCount: args.length, + cwd, + } as const; + const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd })).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "spawn", + cause, + }), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "read-stdout", + cause, + }), + ), + ), + collectStreamAsString(child.stderr).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "read-stderr", + cause, + }), + ), + ), + child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "wait-for-exit", + cause, + }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + return yield* new ReleaseTagListProcessExitError({ + ...context, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + + return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty); }); -const writeOutput = Effect.fn("writeOutput")(function* ( +export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* ( previousTag: string | undefined, writeGithubOutput: boolean, ) { @@ -184,8 +316,23 @@ const writeOutput = Effect.fn("writeOutput")(function* ( if (writeGithubOutput) { const fs = yield* FileSystem.FileSystem; - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); - yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new PreviousReleaseTagGitHubOutputConfigError({ + cause, + }), + ), + ); + yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new PreviousReleaseTagGitHubOutputAppendError({ + outputPath: githubOutputPath, + cause, + }), + ), + ); return; } @@ -208,8 +355,8 @@ const command = Command.make( }, ({ channel, currentTag, githubOutput }) => listGitTags().pipe( - Effect.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), - Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)), + Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), + Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)), ), ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); diff --git a/scripts/sync-reference-repos.test.ts b/scripts/sync-reference-repos.test.ts index aa415dda529..aa7aee57873 100644 --- a/scripts/sync-reference-repos.test.ts +++ b/scripts/sync-reference-repos.test.ts @@ -19,16 +19,22 @@ const encoder = new TextEncoder(); const effectSmol = referenceRepos[0]!; const alchemyEffect = referenceRepos[1]!; -function mockHandle() { +function mockHandle( + options: { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + } = {}, +) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode ?? 0)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode("done\n")), - stderr: Stream.empty, + stdout: Stream.make(encoder.encode(options.stdout ?? "done\n")), + stderr: Stream.make(encoder.encode(options.stderr ?? "")), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -37,6 +43,7 @@ function mockHandle() { function mockSpawnerLayer( commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>, + handle = mockHandle(), ) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -49,7 +56,7 @@ function mockSpawnerLayer( command: childProcess.command, args: childProcess.args, }); - return Effect.succeed(mockHandle()); + return Effect.succeed(handle); }), ); } @@ -85,6 +92,75 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => { }), ); + it.effect("preserves version source read context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "sync-reference-repos-read-error-", + }); + const sourcePath = path.join(rootDir, effectSmol.versionSourcePath); + + const error = yield* resolveReferenceRepoRef(effectSmol, rootDir, false).pipe(Effect.flip); + + if (error._tag !== "ReferenceRepoVersionSourceError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "read"); + assert.equal(error.repoId, effectSmol.id); + assert.equal(error.sourcePath, sourcePath); + assert.ok(error.cause !== undefined); + assert.ok(!error.message.includes(String((error.cause as Error).message))); + }), + ); + + it.effect("preserves version source parse context and the schema cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "sync-reference-repos-parse-error-", + }); + const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath); + yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); + yield* fs.writeFileString(sourcePath, "{"); + + const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip); + + if (error._tag !== "ReferenceRepoVersionSourceError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "parse"); + assert.equal(error.repoId, alchemyEffect.id); + assert.equal(error.sourcePath, sourcePath); + assert.ok(error.cause !== undefined); + assert.ok(!error.message.includes(String((error.cause as Error).message))); + }), + ); + + it.effect("reports the unresolved package path without inventing a cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "sync-reference-repos-resolution-error-", + }); + const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath); + yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); + yield* fs.writeFileString(sourcePath, '{"dependencies":{}}'); + + const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip); + + if (error._tag !== "ReferenceRepoVersionResolutionError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.repoId, alchemyEffect.id); + assert.equal(error.sourcePath, sourcePath); + assert.deepStrictEqual(error.packageVersionPath, ["dependencies", "alchemy"]); + assert.ok(!("cause" in error)); + }), + ); + it.effect("resolves the alchemy-effect tag from the relay package", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -171,10 +247,56 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => { dryRun: true, }).pipe(Effect.flip); - assert.equal( - error.message, - "Unknown reference repo 'missing'. Expected one of: effect-smol, alchemy-effect.", - ); + if (error._tag !== "ReferenceRepoSelectionError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.repoId, "missing"); + assert.deepStrictEqual(error.expectedRepoIds, ["effect-smol", "alchemy-effect"]); + assert.ok(!("cause" in error)); }), ); + + it.effect("reports non-zero git exits without retaining process output", () => { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "sync-reference-repos-exit-error-", + }); + yield* fs.writeFileString( + path.join(rootDir, "pnpm-workspace.yaml"), + "catalog:\n effect: 4.0.0-beta.73\n", + ); + + const error = yield* syncReferenceRepos({ rootDir, repoId: "effect-smol" }).pipe( + Effect.provide( + mockSpawnerLayer( + commands, + mockHandle({ exitCode: 23, stderr: "subtree failed secret-token-value\n" }), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReferenceRepoGitSubtreeError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "exit"); + assert.equal(error.repoId, effectSmol.id); + assert.equal(error.action, "add"); + assert.equal(error.repository, effectSmol.repository); + assert.equal(error.ref, "effect@4.0.0-beta.73"); + assert.equal(error.rootDir, rootDir); + assert.equal(error.argumentCount, commands[0]?.args.length); + assert.equal(error.exitCode, 23); + assert.equal(error.stdoutLength, 5); + assert.equal(error.stderrLength, 34); + assert.notProperty(error, "args"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "secret-token-value"); + assert.ok(!("cause" in error)); + }); + }); }); diff --git a/scripts/sync-reference-repos.ts b/scripts/sync-reference-repos.ts index fa267b10179..b0bc57ad870 100644 --- a/scripts/sync-reference-repos.ts +++ b/scripts/sync-reference-repos.ts @@ -3,7 +3,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Console from "effect/Console"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -32,10 +31,74 @@ export interface ReferenceRepoSyncPlan { readonly args: ReadonlyArray; } -export class ReferenceRepoSyncError extends Data.TaggedError("ReferenceRepoSyncError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class ReferenceRepoSelectionError extends Schema.TaggedErrorClass()( + "ReferenceRepoSelectionError", + { + repoId: Schema.String, + expectedRepoIds: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Unknown reference repo "${this.repoId}". Expected one of: ${this.expectedRepoIds.join(", ")}.`; + } +} + +export class ReferenceRepoVersionSourceError extends Schema.TaggedErrorClass()( + "ReferenceRepoVersionSourceError", + { + operation: Schema.Literals(["read", "parse"]), + repoId: Schema.String, + sourcePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Reference repo "${this.repoId}" version source operation "${this.operation}" failed for ${this.sourcePath}.`; + } +} + +export class ReferenceRepoVersionResolutionError extends Schema.TaggedErrorClass()( + "ReferenceRepoVersionResolutionError", + { + repoId: Schema.String, + sourcePath: Schema.String, + packageVersionPath: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `No version was found for reference repo "${this.repoId}" at ${this.sourcePath}:${this.packageVersionPath.join(".")}.`; + } +} + +export class ReferenceRepoGitSubtreeError extends Schema.TaggedErrorClass()( + "ReferenceRepoGitSubtreeError", + { + operation: Schema.Literals(["spawn", "communicate", "exit"]), + repoId: Schema.String, + action: Schema.Literals(["add", "pull"]), + repository: Schema.String, + ref: Schema.String, + rootDir: Schema.String, + argumentCount: Schema.Number, + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Git subtree ${this.action} for reference repo "${this.repoId}" failed during "${this.operation}".`; + } +} + +export const ReferenceRepoSyncError = Schema.Union([ + ReferenceRepoSelectionError, + ReferenceRepoVersionSourceError, + ReferenceRepoVersionResolutionError, + ReferenceRepoGitSubtreeError, +]); +export type ReferenceRepoSyncError = typeof ReferenceRepoSyncError.Type; +export const isReferenceRepoSyncError = Schema.is(ReferenceRepoSyncError); const decodeJsonSource = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown)); @@ -61,26 +124,21 @@ function readNestedString(input: unknown, keys: ReadonlyArray): string | } function decodeVersionSource( + repo: ReferenceRepo, sourcePath: string, content: string, ): Effect.Effect { - if (sourcePath.endsWith(".yaml") || sourcePath.endsWith(".yml")) { - return decodeYamlSource(content).pipe( - Effect.mapError( - (cause) => - new ReferenceRepoSyncError({ - message: `Unable to parse version source ${sourcePath}.`, - cause, - }), - ), - ); - } - - return decodeJsonSource(content).pipe( + const decode = + repo.versionSourcePath.endsWith(".yaml") || repo.versionSourcePath.endsWith(".yml") + ? decodeYamlSource + : decodeJsonSource; + return decode(content).pipe( Effect.mapError( (cause) => - new ReferenceRepoSyncError({ - message: `Unable to parse version source ${sourcePath}.`, + new ReferenceRepoVersionSourceError({ + operation: "parse", + repoId: repo.id, + sourcePath, cause, }), ), @@ -98,10 +156,9 @@ function getSelectedRepos( return repo ? Effect.succeed([repo]) : Effect.fail( - new ReferenceRepoSyncError({ - message: `Unknown reference repo '${repoId}'. Expected one of: ${referenceRepos - .map((candidate) => candidate.id) - .join(", ")}.`, + new ReferenceRepoSelectionError({ + repoId, + expectedRepoIds: referenceRepos.map((candidate) => candidate.id), }), ); } @@ -121,20 +178,22 @@ export const resolveReferenceRepoRef = Effect.fn("resolveReferenceRepoRef")(func const versionSourceContent = yield* fs.readFileString(versionSourcePath).pipe( Effect.mapError( (cause) => - new ReferenceRepoSyncError({ - message: `Unable to read package version for '${repo.id}' from ${versionSourcePath}.`, + new ReferenceRepoVersionSourceError({ + operation: "read", + repoId: repo.id, + sourcePath: versionSourcePath, cause, }), ), ); - const versionSource = yield* decodeVersionSource(repo.versionSourcePath, versionSourceContent); + const versionSource = yield* decodeVersionSource(repo, versionSourcePath, versionSourceContent); const version = readNestedString(versionSource, repo.packageVersionPath); if (!version) { - return yield* new ReferenceRepoSyncError({ - message: `Unable to resolve package version for '${repo.id}' at ${repo.versionSourcePath}:${repo.packageVersionPath.join( - ".", - )}.`, + return yield* new ReferenceRepoVersionResolutionError({ + repoId: repo.id, + sourcePath: versionSourcePath, + packageVersionPath: repo.packageVersionPath, }); } @@ -163,11 +222,20 @@ export const planReferenceRepoSync = Effect.fn("planReferenceRepoSync")(function const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRepoSyncPlan) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const errorContext = { + repoId: plan.repo.id, + action: plan.action, + repository: plan.repo.repository, + ref: plan.ref, + rootDir, + argumentCount: plan.args.length, + } as const; const child = yield* spawner.spawn(ChildProcess.make("git", plan.args, { cwd: rootDir })).pipe( Effect.mapError( (cause) => - new ReferenceRepoSyncError({ - message: `Unable to start git subtree ${plan.action} for '${plan.repo.id}'.`, + new ReferenceRepoGitSubtreeError({ + ...errorContext, + operation: "spawn", cause, }), ), @@ -182,16 +250,21 @@ const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRe ).pipe( Effect.mapError( (cause) => - new ReferenceRepoSyncError({ - message: `Unable to run git subtree ${plan.action} for '${plan.repo.id}'.`, + new ReferenceRepoGitSubtreeError({ + ...errorContext, + operation: "communicate", cause, }), ), ); if (exitCode !== 0) { - return yield* new ReferenceRepoSyncError({ - message: `git subtree ${plan.action} failed for '${plan.repo.id}' with exit code ${exitCode}.\n${stderr.trim()}`, + return yield* new ReferenceRepoGitSubtreeError({ + ...errorContext, + operation: "exit", + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, }); } diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 802e13f35d9..01a27f3273b 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -1,16 +1,21 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { Command, CliError } from "effect/unstable/cli"; import * as TestConsole from "effect/testing/TestConsole"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { + ReleaseGitHubOutputConfigurationError, + ReleaseGitHubOutputWriteError, + ReleasePackageManifestError, releasePackageFiles, updateReleasePackageVersions, updateReleasePackageVersionsCommand, @@ -103,6 +108,73 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { }), ); + it.effect("preserves manifest read context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-read-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip); + + assert.instanceOf(error, ReleasePackageManifestError); + assert.equal(error.operation, "read"); + assert.equal(error.filePath, filePath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, `Failed to read release package manifest '${filePath}'.`); + }), + ); + + it.effect("preserves manifest decode context and the schema cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-decode-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + yield* fs.writeFileString(filePath, "not json"); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip); + + assert.equal(error.operation, "decode"); + assert.equal(error.filePath, filePath); + assert.isTrue(Schema.isSchemaError(error.cause)); + assert.equal(error.message, `Failed to decode release package manifest '${filePath}'.`); + }), + ); + + it.effect("preserves manifest write context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-write-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + yield* fs.chmod(filePath, 0o400); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip, Effect.ensuring(fs.chmod(filePath, 0o600).pipe(Effect.orDie))); + + assert.equal(error.operation, "write"); + assert.equal(error.filePath, filePath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, `Failed to write release package manifest '${filePath}'.`); + }), + ); + it.effect("accepts flags before the version positional and appends changed output", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -164,9 +236,43 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { Effect.flip, ); + assert.instanceOf(error, ReleaseGitHubOutputConfigurationError); + assert.instanceOf(error.cause, Config.ConfigError); + assert.equal( + error.message, + "Failed to resolve GITHUB_OUTPUT for release package version output.", + ); + }), + ); + + it.effect("preserves GITHUB_OUTPUT write context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-output-error-", + }); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + const error = yield* runCli(["4.0.0", "--root", baseDir, "--github-output"]).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + GITHUB_OUTPUT: baseDir, + }, + }), + ), + ), + Effect.flip, + ); + + assert.instanceOf(error, ReleaseGitHubOutputWriteError); + assert.equal(error.filePath, baseDir); + assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal( error.message, - 'SchemaError(Expected string, got undefined\n at ["GITHUB_OUTPUT"])', + `Failed to append release package version output to '${baseDir}'.`, ); }), ); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index cebf434d0ae..5465b508512 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -12,6 +12,40 @@ import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +export class ReleasePackageManifestError extends Schema.TaggedErrorClass()( + "ReleasePackageManifestError", + { + operation: Schema.Literals(["read", "decode", "encode", "write"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} release package manifest '${this.filePath}'.`; + } +} + +export class ReleaseGitHubOutputConfigurationError extends Schema.TaggedErrorClass()( + "ReleaseGitHubOutputConfigurationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to resolve GITHUB_OUTPUT for release package version output."; + } +} + +export class ReleaseGitHubOutputWriteError extends Schema.TaggedErrorClass()( + "ReleaseGitHubOutputWriteError", + { + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append release package version output to '${this.filePath}'.`; + } +} + export const releasePackageFiles = [ "apps/server/package.json", "apps/desktop/package.json", @@ -39,13 +73,50 @@ export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersi for (const relativePath of releasePackageFiles) { const filePath = path.join(rootDir, relativePath); - const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); + const packageJsonText = yield* fs.readFileString(filePath).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "read", + filePath, + cause, + }), + ), + ); + const packageJson = yield* decodePackageJson(packageJsonText).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "decode", + filePath, + cause, + }), + ), + ); if (packageJson.version === version) { continue; } - const packageJsonString = yield* encodePackageJson({ ...packageJson, version }); - yield* fs.writeFileString(filePath, `${packageJsonString}\n`); + const packageJsonString = yield* encodePackageJson({ ...packageJson, version }).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "encode", + filePath, + cause, + }), + ), + ); + yield* fs.writeFileString(filePath, `${packageJsonString}\n`).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "write", + filePath, + cause, + }), + ), + ); changed = true; } @@ -54,8 +125,23 @@ export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersi const writeGithubOutput = Effect.fn("writeGithubOutput")(function* (changed: boolean) { const fs = yield* FileSystem.FileSystem; - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); - yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new ReleaseGitHubOutputConfigurationError({ + cause, + }), + ), + ); + yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new ReleaseGitHubOutputWriteError({ + filePath: githubOutputPath, + cause, + }), + ), + ); }); export const updateReleasePackageVersionsCommand = Command.make( diff --git a/vite.config.ts b/vite.config.ts index 314ebf01e2e..967521100a0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,12 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import * as NodeURL from "node:url"; export default defineConfig({ resolve: { - tsconfigPaths: true, + alias: { + "~": NodeURL.fileURLToPath(new URL("./apps/web/src", import.meta.url)), + }, }, test: { environment: "node", @@ -93,9 +96,22 @@ export default defineConfig({ "typescript/require-array-sort-compare": "off", "typescript/restrict-template-expressions": "off", "typescript/unbound-method": "off", + "eslint/no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@t3tools/client-runtime", + message: + "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.", + }, + ], + }, + ], "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", + "t3code/namespace-node-imports": "error", }, options: { // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics.